rasti 3.0.0 → 4.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -14
- package/dist/rasti.js +1854 -679
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +770 -479
- package/es/Emitter.js +182 -28
- package/es/Model.js +237 -51
- package/es/View.js +95 -53
- package/es/core/Element.js +55 -0
- package/es/core/EventsManager.js +41 -0
- package/es/core/Interpolation.js +70 -0
- package/es/core/InterpolationWrapper.js +14 -0
- package/es/core/Partial.js +12 -0
- package/es/core/PathManager.js +88 -0
- package/es/core/SafeHTML.js +17 -0
- package/es/index.js +13 -0
- package/es/utils/deepFlat.js +4 -2
- package/es/utils/findComment.js +42 -0
- package/es/utils/getAttributesDiff.js +33 -0
- package/es/utils/getAttributesHTML.js +25 -0
- package/es/utils/getResult.js +4 -2
- package/es/utils/parseHTML.js +14 -0
- package/es/utils/syncNode.js +109 -0
- package/es/utils/validateListener.js +14 -0
- package/lib/Component.cjs +771 -480
- package/lib/Emitter.cjs +182 -28
- package/lib/Model.cjs +237 -51
- package/lib/View.cjs +95 -53
- package/lib/core/Element.cjs +57 -0
- package/lib/core/EventsManager.cjs +43 -0
- package/lib/core/Interpolation.cjs +72 -0
- package/lib/core/InterpolationWrapper.cjs +16 -0
- package/lib/core/Partial.cjs +14 -0
- package/lib/core/PathManager.cjs +90 -0
- package/lib/core/SafeHTML.cjs +19 -0
- package/lib/index.cjs +13 -0
- package/lib/utils/deepFlat.cjs +4 -2
- package/lib/utils/findComment.cjs +44 -0
- package/lib/utils/getAttributesDiff.cjs +35 -0
- package/lib/utils/getAttributesHTML.cjs +27 -0
- package/lib/utils/getResult.cjs +4 -2
- package/lib/utils/parseHTML.cjs +16 -0
- package/lib/utils/syncNode.cjs +111 -0
- package/lib/utils/validateListener.cjs +16 -0
- package/package.json +11 -8
- package/src/Component.js +767 -478
- package/src/Emitter.js +182 -28
- package/src/Model.js +236 -51
- package/src/View.js +95 -53
- package/src/core/Element.js +55 -0
- package/src/core/EventsManager.js +41 -0
- package/src/core/Interpolation.js +70 -0
- package/src/core/InterpolationWrapper.js +14 -0
- package/src/core/Partial.js +12 -0
- package/src/core/PathManager.js +88 -0
- package/src/core/SafeHTML.js +17 -0
- package/src/index.js +4 -5
- package/src/utils/deepFlat.js +4 -2
- package/src/utils/findComment.js +40 -0
- package/src/utils/getAttributesDiff.js +31 -0
- package/src/utils/getAttributesHTML.js +23 -0
- package/src/utils/getResult.js +6 -2
- package/src/utils/parseHTML.js +12 -0
- package/src/utils/syncNode.js +107 -0
- package/src/utils/validateListener.js +12 -0
package/dist/rasti.js
CHANGED
|
@@ -4,12 +4,52 @@
|
|
|
4
4
|
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.Rasti = {}));
|
|
5
5
|
})(this, (function (exports) { 'use strict';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Validates that the listener is a function.
|
|
9
|
+
* @param {Function} listener The listener to validate.
|
|
10
|
+
* @throws {TypeError} If the listener is not a function.
|
|
11
|
+
* @module
|
|
12
|
+
* @private
|
|
13
|
+
*/
|
|
14
|
+
function validateListener(listener) {
|
|
15
|
+
if (typeof listener !== 'function') {
|
|
16
|
+
throw new TypeError('Listener must be a function');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
7
20
|
/**
|
|
8
21
|
* `Emitter` is a class that provides an easy way to implement the observer pattern
|
|
9
22
|
* in your applications.
|
|
10
23
|
* It can be extended to create new classes that have the ability to emit and bind custom named events.
|
|
11
24
|
* Emitter is used by `Model` and `View` classes, which inherit from it to implement
|
|
12
25
|
* event-driven functionality.
|
|
26
|
+
*
|
|
27
|
+
* ## Inverse of Control Pattern
|
|
28
|
+
*
|
|
29
|
+
* The Emitter class includes "inverse of control" methods (`listenTo`, `listenToOnce`, `stopListening`)
|
|
30
|
+
* that allow an object to manage its own listening relationships. Instead of:
|
|
31
|
+
*
|
|
32
|
+
* ```javascript
|
|
33
|
+
* // Traditional approach - harder to clean up
|
|
34
|
+
* otherObject.on('change', this.myHandler);
|
|
35
|
+
* otherObject.on('destroy', this.cleanup);
|
|
36
|
+
* // Later you need to remember to clean up each listener
|
|
37
|
+
* otherObject.off('change', this.myHandler);
|
|
38
|
+
* otherObject.off('destroy', this.cleanup);
|
|
39
|
+
* ```
|
|
40
|
+
*
|
|
41
|
+
* You can use:
|
|
42
|
+
*
|
|
43
|
+
* ```javascript
|
|
44
|
+
* // Inverse of control - easier cleanup
|
|
45
|
+
* this.listenTo(otherObject, 'change', this.myHandler);
|
|
46
|
+
* this.listenTo(otherObject, 'destroy', this.cleanup);
|
|
47
|
+
* // Later, clean up ALL listeners at once
|
|
48
|
+
* this.stopListening(); // Removes all listening relationships
|
|
49
|
+
* ```
|
|
50
|
+
*
|
|
51
|
+
* This pattern is particularly useful for preventing memory leaks and simplifying cleanup
|
|
52
|
+
* in component lifecycle management.
|
|
13
53
|
*
|
|
14
54
|
* @module
|
|
15
55
|
* @example
|
|
@@ -45,20 +85,20 @@
|
|
|
45
85
|
/**
|
|
46
86
|
* Adds event listener.
|
|
47
87
|
* @param {string} type Type of the event (e.g. `change`).
|
|
48
|
-
* @param {
|
|
88
|
+
* @param {Function} listener Callback function to be called when the event is emitted.
|
|
89
|
+
* @return {Function} A function to remove the listener.
|
|
49
90
|
* @example
|
|
50
91
|
* // Re render when model changes.
|
|
51
92
|
* this.model.on('change', this.render.bind(this));
|
|
52
93
|
*/
|
|
53
94
|
on(type, listener) {
|
|
54
95
|
// Validate listener.
|
|
55
|
-
|
|
56
|
-
throw new TypeError('Listener must be a function');
|
|
57
|
-
}
|
|
96
|
+
validateListener(listener);
|
|
58
97
|
// Create listeners object if it doesn't exist.
|
|
59
98
|
if (!this.listeners) this.listeners = {};
|
|
99
|
+
// Every type must have an array of listeners.
|
|
60
100
|
if (!this.listeners[type]) this.listeners[type] = [];
|
|
61
|
-
// Add listener.
|
|
101
|
+
// Add listener to the array of listeners.
|
|
62
102
|
this.listeners[type].push(listener);
|
|
63
103
|
// Return a function to remove the listener.
|
|
64
104
|
return () => this.off(type, listener);
|
|
@@ -67,33 +107,47 @@
|
|
|
67
107
|
/**
|
|
68
108
|
* Adds event listener that executes once.
|
|
69
109
|
* @param {string} type Type of the event (e.g. `change`).
|
|
70
|
-
* @param {
|
|
110
|
+
* @param {Function} listener Callback function to be called when the event is emitted.
|
|
111
|
+
* @return {Function} A function to remove the listener.
|
|
71
112
|
* @example
|
|
72
113
|
* // Log a message once when model changes.
|
|
73
114
|
* this.model.once('change', () => console.log('This will happen once'));
|
|
74
115
|
*/
|
|
75
116
|
once(type, listener) {
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
self.off(type, listener);
|
|
84
|
-
};
|
|
85
|
-
}
|
|
117
|
+
// Validate listener.
|
|
118
|
+
validateListener(listener);
|
|
119
|
+
// Wrap listener to remove it after it is called.
|
|
120
|
+
const wrapper = (...args) => {
|
|
121
|
+
listener(...args);
|
|
122
|
+
this.off(type, wrapper);
|
|
123
|
+
};
|
|
86
124
|
// Add listener.
|
|
87
|
-
return this.on(type,
|
|
125
|
+
return this.on(type, wrapper);
|
|
88
126
|
}
|
|
89
127
|
|
|
90
128
|
/**
|
|
91
|
-
* Removes event listeners.
|
|
92
|
-
* @param {string} [type] Type of the event (e.g. `change`). If
|
|
93
|
-
* @param {
|
|
129
|
+
* Removes event listeners with flexible parameter combinations.
|
|
130
|
+
* @param {string} [type] Type of the event (e.g. `change`). If not provided, removes ALL listeners from this emitter.
|
|
131
|
+
* @param {Function} [listener] Specific callback function to remove. If not provided, removes all listeners for the specified type.
|
|
132
|
+
*
|
|
133
|
+
* **Behavior based on parameters:**
|
|
134
|
+
* - `off()` - Removes ALL listeners from this emitter
|
|
135
|
+
* - `off(type)` - Removes all listeners for the specified event type
|
|
136
|
+
* - `off(type, listener)` - Removes the specific listener for the specified event type
|
|
137
|
+
*
|
|
138
|
+
* @example
|
|
139
|
+
* // Remove all listeners from this emitter
|
|
140
|
+
* this.model.off();
|
|
141
|
+
*
|
|
94
142
|
* @example
|
|
95
|
-
* //
|
|
143
|
+
* // Remove all 'change' event listeners
|
|
96
144
|
* this.model.off('change');
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* // Remove specific listener for 'change' events
|
|
148
|
+
* const myListener = () => console.log('changed');
|
|
149
|
+
* this.model.on('change', myListener);
|
|
150
|
+
* this.model.off('change', myListener);
|
|
97
151
|
*/
|
|
98
152
|
off(type, listener) {
|
|
99
153
|
// No listeners.
|
|
@@ -120,33 +174,146 @@
|
|
|
120
174
|
/**
|
|
121
175
|
* Emits event of specified type. Listeners will receive specified arguments.
|
|
122
176
|
* @param {string} type Type of the event (e.g. `change`).
|
|
123
|
-
* @param {any} [
|
|
177
|
+
* @param {...any} [args] Optional arguments to be passed to listeners.
|
|
124
178
|
* @example
|
|
125
|
-
* // Emit validation error event
|
|
179
|
+
* // Emit validation error event with no arguments
|
|
126
180
|
* this.emit('invalid');
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* // Emit change event with data
|
|
184
|
+
* this.emit('change', { field : 'name', value : 'John' });
|
|
127
185
|
*/
|
|
128
186
|
emit(type, ...args) {
|
|
129
187
|
// No listeners.
|
|
130
188
|
if (!this.listeners || !this.listeners[type]) return;
|
|
131
189
|
// Call listeners. Use `slice` to make a copy and prevent errors when
|
|
132
190
|
// removing listeners inside a listener.
|
|
133
|
-
this.listeners[type]
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
191
|
+
this.listeners[type].slice().forEach(fn => fn(...args));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Listen to an event of another emitter (Inverse of Control pattern).
|
|
196
|
+
*
|
|
197
|
+
* This method allows this object to manage its own listening relationships,
|
|
198
|
+
* making cleanup easier and preventing memory leaks. Instead of calling
|
|
199
|
+
* `otherEmitter.on()`, you call `this.listenTo(otherEmitter, ...)` which
|
|
200
|
+
* allows this object to track and clean up all its listeners at once.
|
|
201
|
+
*
|
|
202
|
+
* @param {Emitter} emitter The emitter to listen to.
|
|
203
|
+
* @param {string} type The type of the event to listen to.
|
|
204
|
+
* @param {Function} listener The listener to call when the event is emitted.
|
|
205
|
+
* @return {Function} A function to stop listening to the event.
|
|
206
|
+
*
|
|
207
|
+
* @example
|
|
208
|
+
* // Instead of: otherModel.on('change', this.render.bind(this));
|
|
209
|
+
* // Use: this.listenTo(otherModel, 'change', this.render.bind(this));
|
|
210
|
+
* // This way you can later call this.stopListening() to clean up all listeners
|
|
211
|
+
*/
|
|
212
|
+
listenTo(emitter, type, listener) {
|
|
213
|
+
// Add listener to the emitter.
|
|
214
|
+
emitter.on(type, listener);
|
|
215
|
+
// Create listeningTo array if it doesn't exist.
|
|
216
|
+
if (!this.listeningTo) this.listeningTo = [];
|
|
217
|
+
// Add listener to the array of listeners.
|
|
218
|
+
this.listeningTo.push({ emitter, type, listener });
|
|
219
|
+
// Return a function to stop listening to the event.
|
|
220
|
+
return () => this.stopListening(emitter, type, listener);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Listen to an event of another emitter and remove the listener after it is called (Inverse of Control pattern).
|
|
225
|
+
*
|
|
226
|
+
* Similar to `listenTo()` but automatically removes the listener after the first execution,
|
|
227
|
+
* like `once()` but with the inverse of control benefits for cleanup management.
|
|
228
|
+
*
|
|
229
|
+
* @param {Emitter} emitter The emitter to listen to.
|
|
230
|
+
* @param {string} type The type of the event to listen to.
|
|
231
|
+
* @param {Function} listener The listener to call when the event is emitted.
|
|
232
|
+
* @return {Function} A function to stop listening to the event.
|
|
233
|
+
*
|
|
234
|
+
* @example
|
|
235
|
+
* // Listen once to another emitter's initialization event
|
|
236
|
+
* this.listenToOnce(otherModel, 'initialized', () => {
|
|
237
|
+
* console.log('Other model initialized');
|
|
238
|
+
* });
|
|
239
|
+
*/
|
|
240
|
+
listenToOnce(emitter, type, listener) {
|
|
241
|
+
validateListener(listener);
|
|
242
|
+
// Wrap listener to remove it after it is called.
|
|
243
|
+
const wrapper = (...args) => {
|
|
244
|
+
listener(...args);
|
|
245
|
+
this.stopListening(emitter, type, wrapper);
|
|
246
|
+
};
|
|
247
|
+
// Add listener.
|
|
248
|
+
return this.listenTo(emitter, type, wrapper);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Stop listening to events from other emitters (Inverse of Control pattern).
|
|
253
|
+
*
|
|
254
|
+
* This method provides flexible cleanup of listening relationships established with `listenTo()`.
|
|
255
|
+
* All parameters are optional, allowing different levels of cleanup granularity.
|
|
256
|
+
*
|
|
257
|
+
* @param {Emitter} [emitter] The emitter to stop listening to. If not provided, stops listening to ALL emitters.
|
|
258
|
+
* @param {string} [type] The type of event to stop listening to. If not provided, stops listening to all event types from the specified emitter.
|
|
259
|
+
* @param {Function} [listener] The specific listener to remove. If not provided, removes all listeners for the specified event type from the specified emitter.
|
|
260
|
+
*
|
|
261
|
+
* **Behavior based on parameters:**
|
|
262
|
+
* - `stopListening()` - Stops listening to ALL events from ALL emitters
|
|
263
|
+
* - `stopListening(emitter)` - Stops listening to all events from the specified emitter
|
|
264
|
+
* - `stopListening(emitter, type)` - Stops listening to the specified event type from the specified emitter
|
|
265
|
+
* - `stopListening(emitter, type, listener)` - Stops listening to the specific listener for the specific event from the specific emitter
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* // Stop listening to all events from all emitters (complete cleanup)
|
|
269
|
+
* this.stopListening();
|
|
270
|
+
*
|
|
271
|
+
* @example
|
|
272
|
+
* // Stop listening to all events from a specific emitter
|
|
273
|
+
* this.stopListening(otherModel);
|
|
274
|
+
*
|
|
275
|
+
* @example
|
|
276
|
+
* // Stop listening to 'change' events from a specific emitter
|
|
277
|
+
* this.stopListening(otherModel, 'change');
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* // Stop listening to a specific listener
|
|
281
|
+
* const myListener = () => console.log('changed');
|
|
282
|
+
* this.listenTo(otherModel, 'change', myListener);
|
|
283
|
+
* this.stopListening(otherModel, 'change', myListener);
|
|
284
|
+
*/
|
|
285
|
+
stopListening(emitter, type, listener) {
|
|
286
|
+
// No listeningTo object.
|
|
287
|
+
if (!this.listeningTo) return;
|
|
288
|
+
// Remove listener from the array of listeners.
|
|
289
|
+
this.listeningTo = this.listeningTo.filter(item => {
|
|
290
|
+
if (
|
|
291
|
+
!emitter ||
|
|
292
|
+
(emitter === item.emitter && !type) ||
|
|
293
|
+
(emitter === item.emitter && type === item.type && !listener) ||
|
|
294
|
+
(emitter === item.emitter && type === item.type && listener === item.listener)
|
|
295
|
+
) {
|
|
296
|
+
item.emitter.off(item.type, item.listener);
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
return true;
|
|
300
|
+
});
|
|
301
|
+
// Remove listeningTo object if it's empty.
|
|
302
|
+
if (!this.listeningTo.length) delete this.listeningTo;
|
|
138
303
|
}
|
|
139
304
|
}
|
|
140
305
|
|
|
141
|
-
|
|
306
|
+
/**
|
|
142
307
|
* Evaluate the expression. If it's a function, call it with the provided context and return the result.
|
|
143
308
|
* Otherwise, return the expression as is.
|
|
144
309
|
* @param {any} expression Expression to be evaluated.
|
|
145
310
|
* @param {any} context Context to call the expression.
|
|
146
311
|
* @param {...any} args Arguments to pass to the expression.
|
|
147
312
|
* @return {any} The result of the expression.
|
|
313
|
+
* @module
|
|
314
|
+
* @private
|
|
148
315
|
*/
|
|
149
|
-
|
|
316
|
+
const getResult = (expression, context, ...args) =>
|
|
150
317
|
typeof expression !== 'function' ? expression :
|
|
151
318
|
expression.apply(context, args);
|
|
152
319
|
|
|
@@ -157,55 +324,113 @@
|
|
|
157
324
|
* A `Model` manages an internal table of data attributes and triggers change events when any of its data is modified.
|
|
158
325
|
* Models may handle syncing data with a persistence layer. To design your models, create atomic, reusable objects
|
|
159
326
|
* that contain all the necessary functions for manipulating their specific data.
|
|
160
|
-
* Models should be easily passed throughout your app and used anywhere the corresponding data is needed.
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
327
|
+
* Models should be easily passed throughout your app and used anywhere the corresponding data is needed.
|
|
328
|
+
*
|
|
329
|
+
* ## Construction Flow
|
|
330
|
+
* 1. `preinitialize()` is called with all constructor arguments
|
|
331
|
+
* 2. `this.defaults` are resolved (if function, it's called and bound to the model)
|
|
332
|
+
* 3. `parse()` is called with all constructor arguments to process the data
|
|
333
|
+
* 4. `this.attributes` is built by merging defaults and parsed data
|
|
334
|
+
* 5. Getters/setters are generated for each attribute to emit change events
|
|
335
|
+
*
|
|
165
336
|
* @module
|
|
166
|
-
* @extends
|
|
167
|
-
* @param {object} attributes
|
|
168
|
-
* @
|
|
337
|
+
* @extends Emitter
|
|
338
|
+
* @param {object} [attributes={}] Primary data object containing model attributes
|
|
339
|
+
* @param {...*} [args] Additional arguments passed to `preinitialize` and `parse` methods
|
|
340
|
+
* @property {object|Function} defaults Default attributes for the model. If a function, it's called bound to the model instance to get defaults.
|
|
169
341
|
* @property {object} previous Object containing previous attributes when a change occurs.
|
|
342
|
+
* @property {string} attributePrefix Static property that defines a prefix for generated getters/setters. Defaults to empty string.
|
|
170
343
|
* @example
|
|
171
344
|
* import { Model } from 'rasti';
|
|
172
|
-
*
|
|
173
|
-
*
|
|
345
|
+
*
|
|
346
|
+
* // User model
|
|
347
|
+
* class User extends Model {
|
|
174
348
|
* preinitialize() {
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
349
|
+
* this.defaults = { name : '', email : '', role : 'user' };
|
|
350
|
+
* }
|
|
351
|
+
* }
|
|
352
|
+
* // Order model with nested User and custom methods
|
|
353
|
+
* class Order extends Model {
|
|
354
|
+
* preinitialize(attributes, options = {}) {
|
|
179
355
|
* this.defaults = {
|
|
180
|
-
*
|
|
181
|
-
*
|
|
356
|
+
* id : null,
|
|
357
|
+
* total : 0,
|
|
358
|
+
* status : 'pending',
|
|
359
|
+
* user : null
|
|
182
360
|
* };
|
|
361
|
+
*
|
|
362
|
+
* this.apiUrl = options.apiUrl || '/api/orders';
|
|
183
363
|
* }
|
|
184
364
|
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
* //
|
|
189
|
-
*
|
|
190
|
-
*
|
|
365
|
+
* parse(data, options = {}) {
|
|
366
|
+
* const parsed = { ...data };
|
|
367
|
+
*
|
|
368
|
+
* // Convert user object to User model instance
|
|
369
|
+
* if (data.user && !(data.user instanceof User)) {
|
|
370
|
+
* parsed.user = new User(data.user);
|
|
371
|
+
* }
|
|
372
|
+
*
|
|
373
|
+
* return parsed;
|
|
374
|
+
* }
|
|
375
|
+
*
|
|
376
|
+
* toJSON() {
|
|
377
|
+
* const result = {};
|
|
378
|
+
* for (const [key, value] of Object.entries(this.attributes)) {
|
|
379
|
+
* if (value instanceof Model) {
|
|
380
|
+
* result[key] = value.toJSON();
|
|
381
|
+
* } else {
|
|
382
|
+
* result[key] = value;
|
|
383
|
+
* }
|
|
384
|
+
* }
|
|
385
|
+
* return result;
|
|
386
|
+
* }
|
|
387
|
+
*
|
|
388
|
+
* async fetch() {
|
|
389
|
+
* try {
|
|
390
|
+
* const response = await fetch(`${this.apiUrl}/${this.id}`);
|
|
391
|
+
* const data = await response.json();
|
|
392
|
+
*
|
|
393
|
+
* // Parse the fetched data and update model
|
|
394
|
+
* const parsed = this.parse(data);
|
|
395
|
+
* this.set(parsed, { source : 'fetch' });
|
|
396
|
+
*
|
|
397
|
+
* return this;
|
|
398
|
+
* } catch (error) {
|
|
399
|
+
* console.error('Failed to fetch order:', error);
|
|
400
|
+
* throw error;
|
|
401
|
+
* }
|
|
191
402
|
* }
|
|
192
403
|
* }
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
404
|
+
*
|
|
405
|
+
* // Create order with nested user data
|
|
406
|
+
* const order = new Order({
|
|
407
|
+
* id : 123,
|
|
408
|
+
* total : 99.99,
|
|
409
|
+
* user : { name : 'Alice', email : 'alice@example.com' }
|
|
410
|
+
* });
|
|
411
|
+
*
|
|
412
|
+
* console.log(order.user instanceof User); // true
|
|
413
|
+
* // Serialize with nested models
|
|
414
|
+
* const json = order.toJSON();
|
|
415
|
+
* console.log(json); // { id: 123, total: 99.99, status: 'pending', user: { name: 'Alice', email: 'alice@example.com', role: 'user' } }
|
|
416
|
+
*
|
|
417
|
+
* // Listen to fetch updates
|
|
418
|
+
* order.on('change', (model, changed, options) => {
|
|
419
|
+
* if (options?.source === 'fetch') {
|
|
420
|
+
* console.log('Order updated from server:', changed);
|
|
421
|
+
* }
|
|
422
|
+
* });
|
|
423
|
+
*
|
|
424
|
+
* // Fetch latest data from server
|
|
425
|
+
* await order.fetch();
|
|
199
426
|
*/
|
|
200
427
|
class Model extends Emitter {
|
|
201
|
-
constructor(
|
|
428
|
+
constructor() {
|
|
202
429
|
super();
|
|
203
430
|
// Call preinitialize.
|
|
204
431
|
this.preinitialize.apply(this, arguments);
|
|
205
|
-
// Get defaults. If `this.defaults` is a function, call it.
|
|
206
|
-
const defaults = getResult(this.defaults, this) || {};
|
|
207
432
|
// Set attributes object with defaults and passed attributes.
|
|
208
|
-
this.attributes = Object.assign({}, defaults,
|
|
433
|
+
this.attributes = Object.assign({}, getResult(this.defaults, this), this.parse.apply(this, arguments));
|
|
209
434
|
// Object to store previous attributes when a change occurs.
|
|
210
435
|
this.previous = {};
|
|
211
436
|
// Generate getters/setters for every attribute.
|
|
@@ -213,21 +438,53 @@
|
|
|
213
438
|
}
|
|
214
439
|
|
|
215
440
|
/**
|
|
216
|
-
*
|
|
217
|
-
*
|
|
441
|
+
* Called before any instantiation logic runs for the Model.
|
|
442
|
+
* Receives all constructor arguments, allowing for flexible initialization patterns.
|
|
443
|
+
* Use this to set up `defaults`, configure the model, or handle custom constructor arguments.
|
|
444
|
+
* @param {object} [attributes={}] Primary data object containing model attributes
|
|
445
|
+
* @param {...*} [args] Additional arguments passed from the constructor
|
|
446
|
+
* @example
|
|
447
|
+
* class User extends Model {
|
|
448
|
+
* preinitialize(attributes, options = {}) {
|
|
449
|
+
* this.defaults = { name : '', role : options.defaultRole || 'user' };
|
|
450
|
+
* this.apiEndpoint = options.apiEndpoint || '/users';
|
|
451
|
+
* }
|
|
452
|
+
* }
|
|
453
|
+
* const user = new User({ name : 'Alice' }, { defaultRole : 'admin', apiEndpoint : '/api/users' });
|
|
218
454
|
*/
|
|
219
455
|
preinitialize() {}
|
|
220
456
|
|
|
221
457
|
/**
|
|
222
|
-
* Generate getter/setter for the given key
|
|
223
|
-
*
|
|
224
|
-
* for `this.attributes`.
|
|
225
|
-
*
|
|
458
|
+
* Generate getter/setter for the given attribute key to emit `change` events.
|
|
459
|
+
* The property name uses `attributePrefix` + key (e.g., with prefix 'attr_', key 'name' becomes 'attr_name').
|
|
460
|
+
* Called internally by the constructor for each key in `this.attributes`.
|
|
461
|
+
* Override with an empty method if you don't want automatic getters/setters.
|
|
462
|
+
*
|
|
463
|
+
* @param {string} key Attribute key from `this.attributes`
|
|
464
|
+
* @example
|
|
465
|
+
* // Custom prefix for all attributes
|
|
466
|
+
* class PrefixedModel extends Model {
|
|
467
|
+
* static attributePrefix = 'attr_';
|
|
468
|
+
* }
|
|
469
|
+
* const model = new PrefixedModel({ name: 'Alice' });
|
|
470
|
+
* console.log(model.attr_name); // 'Alice'
|
|
471
|
+
*
|
|
472
|
+
* // Disable automatic getters/setters
|
|
473
|
+
* class ManualModel extends Model {
|
|
474
|
+
* defineAttribute() {
|
|
475
|
+
* // Empty - no getters/setters generated
|
|
476
|
+
* }
|
|
477
|
+
*
|
|
478
|
+
* getName() {
|
|
479
|
+
* return this.get('name'); // Manual getter
|
|
480
|
+
* }
|
|
481
|
+
* }
|
|
226
482
|
*/
|
|
227
483
|
defineAttribute(key) {
|
|
228
484
|
Object.defineProperty(
|
|
229
485
|
this,
|
|
230
|
-
key
|
|
486
|
+
`${this.constructor.attributePrefix}${key}`,
|
|
487
|
+
{
|
|
231
488
|
get : () => this.get(key),
|
|
232
489
|
set : (value) => { this.set(key, value); }
|
|
233
490
|
}
|
|
@@ -245,18 +502,28 @@
|
|
|
245
502
|
}
|
|
246
503
|
|
|
247
504
|
/**
|
|
248
|
-
* Set
|
|
249
|
-
*
|
|
250
|
-
*
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* @
|
|
256
|
-
* @
|
|
257
|
-
* @
|
|
258
|
-
* @
|
|
259
|
-
*
|
|
505
|
+
* Set one or more attributes into `this.attributes` and emit change events.
|
|
506
|
+
* Supports two call signatures: `set(key, value, ...args)` or `set(object, ...args)`.
|
|
507
|
+
* Additional arguments are passed to change event listeners, enabling custom behavior.
|
|
508
|
+
*
|
|
509
|
+
* @param {string|object} key Attribute key (string) or object containing key-value pairs
|
|
510
|
+
* @param {*} [value] Attribute value (when key is string)
|
|
511
|
+
* @param {...*} [args] Additional arguments passed to event listeners
|
|
512
|
+
* @return {Model} This model instance for chaining
|
|
513
|
+
* @emits change Emitted when any attribute changes. Listeners receive `(model, changedAttributes, ...args)`
|
|
514
|
+
* @emits change:attribute Emitted for each changed attribute. Listeners receive `(model, newValue, ...args)`
|
|
515
|
+
* @example
|
|
516
|
+
* // Basic usage
|
|
517
|
+
* model.set('name', 'Alice');
|
|
518
|
+
* model.set({ name : 'Alice', age : 30 });
|
|
519
|
+
*
|
|
520
|
+
* // With options for listeners
|
|
521
|
+
* model.set('name', 'Bob', { silent : false, validate : true });
|
|
522
|
+
* model.on('change:name', (model, value, options) => {
|
|
523
|
+
* if (options?.validate) {
|
|
524
|
+
* // Custom validation logic
|
|
525
|
+
* }
|
|
526
|
+
* });
|
|
260
527
|
*/
|
|
261
528
|
set(key, value, ...rest) {
|
|
262
529
|
let attrs, args;
|
|
@@ -310,28 +577,105 @@
|
|
|
310
577
|
return this;
|
|
311
578
|
}
|
|
312
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Transforms and validates data before it becomes model attributes.
|
|
582
|
+
* Called during construction with all constructor arguments, allowing flexible data processing.
|
|
583
|
+
* Override this method to transform incoming data, create nested models, or handle different data formats.
|
|
584
|
+
*
|
|
585
|
+
* @param {object} [data={}] Primary data object to be parsed into attributes
|
|
586
|
+
* @param {...*} [args] Additional arguments from constructor, useful for parsing options
|
|
587
|
+
* @return {object} Processed data that will become the model's attributes
|
|
588
|
+
* @example
|
|
589
|
+
* // Transform nested objects into models
|
|
590
|
+
* class User extends Model {}
|
|
591
|
+
* class Order extends Model {
|
|
592
|
+
* parse(data, options = {}) {
|
|
593
|
+
* // Skip parsing if requested
|
|
594
|
+
* if (options.raw) return data;
|
|
595
|
+
* // Transform user data into User model
|
|
596
|
+
* const parsed = { ...data };
|
|
597
|
+
* if (data.user && !(data.user instanceof User)) {
|
|
598
|
+
* parsed.user = new User(data.user);
|
|
599
|
+
* }
|
|
600
|
+
* return parsed;
|
|
601
|
+
* }
|
|
602
|
+
* }
|
|
603
|
+
*
|
|
604
|
+
* // Usage with parsing options
|
|
605
|
+
* const order1 = new Order({ id : 1, user : { name : 'Alice' } }); // user becomes User model
|
|
606
|
+
* const order2 = new Order({ id : 2, user : { name : 'Bob' } }, { raw : true }); // user stays plain object
|
|
607
|
+
*/
|
|
608
|
+
parse(data) {
|
|
609
|
+
return data;
|
|
610
|
+
}
|
|
611
|
+
|
|
313
612
|
/**
|
|
314
613
|
* Return object representation of the model to be used for JSON serialization.
|
|
315
614
|
* By default returns a copy of `this.attributes`.
|
|
615
|
+
* You can override this method to customize serialization behavior, such as calling `toJSON` recursively on nested Model instances.
|
|
316
616
|
* @return {object} Object representation of the model to be used for JSON serialization.
|
|
617
|
+
* @example
|
|
618
|
+
* // Basic usage - returns a copy of model attributes:
|
|
619
|
+
* const user = new Model({ name : 'Alice', age : 30 });
|
|
620
|
+
* const json = user.toJSON();
|
|
621
|
+
* console.log(json); // { name : 'Alice', age : 30 }
|
|
622
|
+
*
|
|
623
|
+
* // Override toJSON for recursive serialization of nested models:
|
|
624
|
+
* class User extends Model {}
|
|
625
|
+
* class Order extends Model {
|
|
626
|
+
* parse(data) {
|
|
627
|
+
* // Ensure user is always a User model
|
|
628
|
+
* return { ...data, user : data.user instanceof User ? data.user : new User(data.user) };
|
|
629
|
+
* }
|
|
630
|
+
*
|
|
631
|
+
* toJSON() {
|
|
632
|
+
* const result = {};
|
|
633
|
+
* for (const [key, value] of Object.entries(this.attributes)) {
|
|
634
|
+
* if (value instanceof Model) {
|
|
635
|
+
* result[key] = value.toJSON();
|
|
636
|
+
* } else {
|
|
637
|
+
* result[key] = value;
|
|
638
|
+
* }
|
|
639
|
+
* }
|
|
640
|
+
* return result;
|
|
641
|
+
* }
|
|
642
|
+
* }
|
|
643
|
+
* const order = new Order({ id : 1, user : { name : 'Alice' } });
|
|
644
|
+
* const json = order.toJSON();
|
|
645
|
+
* console.log(json); // { id : 1, user : { name : 'Alice' } }
|
|
317
646
|
*/
|
|
318
647
|
toJSON() {
|
|
319
648
|
return Object.assign({}, this.attributes);
|
|
320
649
|
}
|
|
321
650
|
}
|
|
322
651
|
|
|
652
|
+
/**
|
|
653
|
+
* Static property that defines a prefix for generated getters/setters.
|
|
654
|
+
* When set, all attribute properties will be prefixed (e.g., 'attr_name' instead of 'name').
|
|
655
|
+
* Useful for avoiding naming conflicts or creating a consistent property naming convention.
|
|
656
|
+
* @type {string}
|
|
657
|
+
* @default ''
|
|
658
|
+
* @example
|
|
659
|
+
* // Set prefix for all models of this class
|
|
660
|
+
* class ApiModel extends Model {
|
|
661
|
+
* static attributePrefix = 'attr_';
|
|
662
|
+
* }
|
|
663
|
+
*
|
|
664
|
+
* const user = new ApiModel({ name : 'Alice', email : 'alice@example.com' });
|
|
665
|
+
* console.log(user.attr_name); // 'Alice'
|
|
666
|
+
* console.log(user.attr_email); // 'alice@example.com'
|
|
667
|
+
*
|
|
668
|
+
* // Still access via get/set methods without prefix
|
|
669
|
+
* console.log(user.get('name')); // 'Alice'
|
|
670
|
+
* user.set('name', 'Bob');
|
|
671
|
+
* console.log(user.attr_name); // 'Bob'
|
|
672
|
+
*/
|
|
673
|
+
Model.attributePrefix = '';
|
|
674
|
+
|
|
323
675
|
/*
|
|
324
676
|
* These option keys will be extended on the view instance.
|
|
325
677
|
*/
|
|
326
|
-
const viewOptions =
|
|
327
|
-
el : true,
|
|
328
|
-
tag : true,
|
|
329
|
-
attributes : true,
|
|
330
|
-
events : true,
|
|
331
|
-
model : true,
|
|
332
|
-
template : true,
|
|
333
|
-
onDestroy : true
|
|
334
|
-
};
|
|
678
|
+
const viewOptions = ['el', 'tag', 'attributes', 'events', 'model', 'template', 'onDestroy'];
|
|
335
679
|
|
|
336
680
|
/**
|
|
337
681
|
* - Listens for changes and renders the UI.
|
|
@@ -348,14 +692,15 @@
|
|
|
348
692
|
* @module
|
|
349
693
|
* @extends Emitter
|
|
350
694
|
* @param {object} options Object containing options. The following keys will be merged into the view instance: `el`, `tag`, `attributes`, `events`, `model`, `template`, `onDestroy`.
|
|
351
|
-
* @property {node|
|
|
352
|
-
* @property {string|
|
|
353
|
-
* @property {object|
|
|
354
|
-
* @property {object|
|
|
695
|
+
* @property {node|Function} el Every view has a root DOM element stored at `this.el`. If not present, it will be created. If `this.el` is a function, it will be called to get the element at `this.ensureElement`, bound to the view instance. See {@link module_view__ensureelement View.ensureElement}.
|
|
696
|
+
* @property {string|Function} tag If `this.el` is not present, an element will be created using `this.tag` and `this.attributes`. Default is `div`. If it is a function, it will be called to get the tag, bound to the view instance. See {@link module_view__ensureelement View.ensureElement}.
|
|
697
|
+
* @property {object|Function} attributes If `this.el` is not present, an element will be created using `this.tag` and `this.attributes`. If it is a function, it will be called to get the attributes object, bound to the view instance. See {@link module_view__ensureelement View.ensureElement}.
|
|
698
|
+
* @property {object|Function} events Object in the format `{'event selector' : 'listener'}`. It will be used to bind delegated event listeners to the root element. If it is a function, it will be called to get the events object, bound to the view instance. See {@link module_view_delegateevents View.delegateEvents}.
|
|
355
699
|
* @property {object} model A model or any object containing data and business logic.
|
|
356
|
-
* @property {
|
|
700
|
+
* @property {Function} template A function that returns a string with the view's inner HTML. See {@link module_view__render View.render}.
|
|
701
|
+
* @property {number} uid Unique identifier for the view instance. This can be used to generate unique IDs for elements within the view. It is automatically generated and should not be set manually.
|
|
357
702
|
* @example
|
|
358
|
-
* import { View } from 'rasti';
|
|
703
|
+
* import { View, Model } from 'rasti';
|
|
359
704
|
*
|
|
360
705
|
* class Timer extends View {
|
|
361
706
|
* constructor(options) {
|
|
@@ -380,9 +725,6 @@
|
|
|
380
725
|
super();
|
|
381
726
|
// Call preinitialize.
|
|
382
727
|
this.preinitialize.apply(this, arguments);
|
|
383
|
-
// Generate unique id.
|
|
384
|
-
// Useful to generate element ids.
|
|
385
|
-
this.uid = `uid${++View.uid}`;
|
|
386
728
|
// Store delegated event listeners,
|
|
387
729
|
// so they can be unbound later.
|
|
388
730
|
this.delegatedEventListeners = [];
|
|
@@ -391,17 +733,23 @@
|
|
|
391
733
|
this.children = [];
|
|
392
734
|
// Mutable array to store handlers to be called on destroy.
|
|
393
735
|
this.destroyQueue = [];
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
736
|
+
this.viewOptions = [];
|
|
737
|
+
// Extend "this" with options.
|
|
738
|
+
viewOptions.forEach(key => {
|
|
739
|
+
if (key in options) {
|
|
740
|
+
this[key] = options[key];
|
|
741
|
+
this.viewOptions.push(key);
|
|
742
|
+
}
|
|
397
743
|
});
|
|
744
|
+
// Ensure that the view has a unique id at `this.uid`.
|
|
745
|
+
this.ensureUid();
|
|
398
746
|
// Ensure that the view has a root element at `this.el`.
|
|
399
747
|
this.ensureElement();
|
|
400
748
|
}
|
|
401
749
|
|
|
402
750
|
/**
|
|
403
751
|
* If you define a preinitialize method, it will be invoked when the view is first created, before any instantiation logic is run.
|
|
404
|
-
* @param {object}
|
|
752
|
+
* @param {object} options The view options.
|
|
405
753
|
*/
|
|
406
754
|
preinitialize() {}
|
|
407
755
|
|
|
@@ -436,6 +784,8 @@
|
|
|
436
784
|
this.destroyChildren();
|
|
437
785
|
// Undelegate `this.el` event listeners
|
|
438
786
|
this.undelegateEvents();
|
|
787
|
+
// Stop listening to events.
|
|
788
|
+
this.stopListening();
|
|
439
789
|
// Unbind `this` events.
|
|
440
790
|
this.off();
|
|
441
791
|
// Call destroy queue.
|
|
@@ -443,6 +793,8 @@
|
|
|
443
793
|
this.destroyQueue = [];
|
|
444
794
|
// Call onDestroy lifecycle method
|
|
445
795
|
this.onDestroy.apply(this, arguments);
|
|
796
|
+
// Set destroyed flag.
|
|
797
|
+
this.destroyed = true;
|
|
446
798
|
// Return `this` for chaining.
|
|
447
799
|
return this;
|
|
448
800
|
}
|
|
@@ -474,6 +826,13 @@
|
|
|
474
826
|
this.children = [];
|
|
475
827
|
}
|
|
476
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Ensure that the view has a unique id at `this.uid`.
|
|
831
|
+
*/
|
|
832
|
+
ensureUid() {
|
|
833
|
+
if (!this.uid) this.uid = `r-${++View.uid}`;
|
|
834
|
+
}
|
|
835
|
+
|
|
477
836
|
/**
|
|
478
837
|
* Ensure that the view has a root element at `this.el`.
|
|
479
838
|
* You shouldn't call this method directly. It's called from the constructor.
|
|
@@ -540,26 +899,43 @@
|
|
|
540
899
|
* All attached listeners are bound to the view, ensuring that `this` refers to the view object when the listeners are invoked.
|
|
541
900
|
* When `delegateEvents` is called again, possibly with a different events object, all previous listeners are removed and delegated afresh.
|
|
542
901
|
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
902
|
+
* **Listener signature:** `(event, view, matched)`
|
|
903
|
+
* - `event`: The native DOM event object.
|
|
904
|
+
* - `view`: The current view instance (`this`).
|
|
905
|
+
* - `matched`: The element that satisfies the selector. If no selector is provided, it will be the view's root element (`this.el`).
|
|
906
|
+
*
|
|
907
|
+
* If more than one ancestor between `event.target` and the view's root element matches the selector, the listener will be
|
|
908
|
+
* invoked **once for each matched element** (from inner to outer).
|
|
909
|
+
*
|
|
545
910
|
* @param {object} [events] Object in the format `{'event selector' : 'listener'}`. Used to bind delegated event listeners to the root element.
|
|
546
911
|
* @return {Rasti.View} Returns `this` for chaining.
|
|
547
912
|
* @example
|
|
548
|
-
* // Using
|
|
913
|
+
* // Using prototype (recommended for static events)
|
|
549
914
|
* class Modal extends View {
|
|
915
|
+
* onClickOk(event, view, matched) {
|
|
916
|
+
* // matched === the button.ok element that was clicked
|
|
917
|
+
* this.close();
|
|
918
|
+
* }
|
|
919
|
+
*
|
|
920
|
+
* onClickCancel() {
|
|
921
|
+
* this.destroy();
|
|
922
|
+
* }
|
|
923
|
+
* }
|
|
924
|
+
* Modal.prototype.events = {
|
|
925
|
+
* 'click button.ok': 'onClickOk',
|
|
926
|
+
* 'click button.cancel': 'onClickCancel',
|
|
927
|
+
* 'submit form': 'onSubmit'
|
|
928
|
+
* };
|
|
929
|
+
*
|
|
930
|
+
* // Using a function for dynamic events
|
|
931
|
+
* class DynamicView extends View {
|
|
550
932
|
* events() {
|
|
551
933
|
* return {
|
|
552
|
-
*
|
|
553
|
-
* 'click
|
|
934
|
+
* [`click .${this.model.buttonClass}`]: 'onButtonClick',
|
|
935
|
+
* 'click': 'onRootClick'
|
|
554
936
|
* };
|
|
555
937
|
* }
|
|
556
938
|
* }
|
|
557
|
-
*
|
|
558
|
-
* // Using an object.
|
|
559
|
-
* Modal.prototype.events = {
|
|
560
|
-
* 'click button.ok' : 'onClickOkButton',
|
|
561
|
-
* 'click button.cancel' : function() {}
|
|
562
|
-
* };
|
|
563
939
|
*/
|
|
564
940
|
delegateEvents(events) {
|
|
565
941
|
if (!events) events = getResult(this.events, this);
|
|
@@ -576,13 +952,10 @@
|
|
|
576
952
|
const selector = keyParts.join(' ');
|
|
577
953
|
|
|
578
954
|
let listener = events[key];
|
|
579
|
-
// Listener may be a string representing a method name on the view,
|
|
580
|
-
|
|
581
|
-
listener
|
|
582
|
-
|
|
583
|
-
this[listener] :
|
|
584
|
-
listener
|
|
585
|
-
).bind(this);
|
|
955
|
+
// Listener may be a string representing a method name on the view, or a function.
|
|
956
|
+
if (typeof listener === 'string') listener = this[listener];
|
|
957
|
+
// Validate listener is a function.
|
|
958
|
+
validateListener(listener);
|
|
586
959
|
|
|
587
960
|
if (!eventTypes[type]) eventTypes[type] = [];
|
|
588
961
|
|
|
@@ -594,7 +967,20 @@
|
|
|
594
967
|
const typeListener = (event) => {
|
|
595
968
|
// Iterate and run every individual listener if the selector matches.
|
|
596
969
|
eventTypes[type].forEach(({ selector, listener }) => {
|
|
597
|
-
|
|
970
|
+
// No selector provided: invoke listener once with root element.
|
|
971
|
+
if (!selector) {
|
|
972
|
+
listener.call(this, event, this, this.el);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
let node = event.target;
|
|
977
|
+
// Traverse ancestors until reaching the view root (`this.el`).
|
|
978
|
+
while (node && node !== this.el) {
|
|
979
|
+
if (node.matches && node.matches(selector)) {
|
|
980
|
+
listener.call(this, event, this, node);
|
|
981
|
+
}
|
|
982
|
+
node = node.parentElement;
|
|
983
|
+
}
|
|
598
984
|
});
|
|
599
985
|
};
|
|
600
986
|
|
|
@@ -622,17 +1008,16 @@
|
|
|
622
1008
|
}
|
|
623
1009
|
|
|
624
1010
|
/**
|
|
625
|
-
* Renders the view.
|
|
1011
|
+
* Renders the view.
|
|
626
1012
|
* This method should be overridden with custom logic.
|
|
627
1013
|
* The only convention is to manipulate the DOM within the scope of `this.el`,
|
|
628
|
-
* and to return `this` for chaining.
|
|
629
|
-
* If you add any child views, you should call `this.destroyChildren` before re-rendering.
|
|
630
|
-
* The default implementation
|
|
631
|
-
* of calling `this.template`, passing `this.model` as
|
|
632
|
-
* <br><br> ⚠ **Security Notice:** The default implementation utilizes `innerHTML
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
* static method to escape HTML entities in a string.
|
|
1014
|
+
* and to return `this` for chaining.
|
|
1015
|
+
* If you add any child views, you should call `this.destroyChildren` before re-rendering.
|
|
1016
|
+
* The default implementation updates `this.el`'s innerHTML with the result
|
|
1017
|
+
* of calling `this.template`, passing `this.model` as the argument.
|
|
1018
|
+
* <br><br> ⚠ **Security Notice:** The default implementation utilizes `innerHTML`, which may introduce Cross-Site Scripting (XSS) risks.
|
|
1019
|
+
* Ensure that any user-generated content is properly sanitized before inserting it into the DOM.
|
|
1020
|
+
* You can use the {@link #module_view_sanitize View.sanitize} static method to escape HTML entities in a string.
|
|
636
1021
|
* For best practices on secure data handling, refer to the
|
|
637
1022
|
* [OWASP's XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html).<br><br>
|
|
638
1023
|
* @return {Rasti.View} Returns `this` for chaining.
|
|
@@ -649,7 +1034,7 @@
|
|
|
649
1034
|
* Override this method to provide a custom escape function.
|
|
650
1035
|
* This method is inherited by {@link #module_component Component} and used to escape template interpolations.
|
|
651
1036
|
* @static
|
|
652
|
-
* @param {string}
|
|
1037
|
+
* @param {string} value String to escape.
|
|
653
1038
|
* @return {string} Escaped string.
|
|
654
1039
|
*/
|
|
655
1040
|
static sanitize(value) {
|
|
@@ -663,24 +1048,24 @@
|
|
|
663
1048
|
}
|
|
664
1049
|
}
|
|
665
1050
|
|
|
666
|
-
|
|
667
|
-
*
|
|
1051
|
+
/**
|
|
1052
|
+
* Counter for generating unique IDs for view instances.
|
|
1053
|
+
* This is primarily used to assign unique identifiers to each view instance (`this.uid`), which can be helpful for tasks like
|
|
1054
|
+
* generating element IDs.
|
|
1055
|
+
* {@link #module_component Component}s use `this.uid` to generate data attributes for their elements, to be looked up on hydration.
|
|
1056
|
+
* For server-side rendering, this counter should be reset to `0` on every request to ensure that the generated
|
|
1057
|
+
* unique IDs match those on the client, enabling seamless hydration of components.
|
|
1058
|
+
* @static
|
|
1059
|
+
* @type {number}
|
|
1060
|
+
* @default 0
|
|
668
1061
|
*/
|
|
669
1062
|
View.uid = 0;
|
|
670
1063
|
|
|
671
|
-
|
|
672
|
-
* Flatten an array recursively
|
|
673
|
-
* @param {Array} arr Array to flat recursively
|
|
674
|
-
* @return {Array} Flat array
|
|
675
|
-
*/
|
|
676
|
-
const deepFlat = (arr) => arr.reduce((acc, val) => {
|
|
677
|
-
if (Array.isArray(val)) acc.push(...deepFlat(val));
|
|
678
|
-
else acc.push(val);
|
|
679
|
-
return acc;
|
|
680
|
-
}, []);
|
|
681
|
-
|
|
682
|
-
/*
|
|
1064
|
+
/**
|
|
683
1065
|
* Wrapper class for HTML strings marked as safe.
|
|
1066
|
+
* @param {string} value The HTML string to be marked as safe.
|
|
1067
|
+
* @property {string} value The HTML string.
|
|
1068
|
+
* @private
|
|
684
1069
|
*/
|
|
685
1070
|
class SafeHTML {
|
|
686
1071
|
constructor(value) {
|
|
@@ -692,413 +1077,1110 @@
|
|
|
692
1077
|
}
|
|
693
1078
|
}
|
|
694
1079
|
|
|
695
|
-
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* @
|
|
699
|
-
* @param {any} context The context to call the expression with.
|
|
700
|
-
* @return {any} The result of the evaluated expression.
|
|
1080
|
+
/**
|
|
1081
|
+
* Wrapper class for partial templates that preserves structure.
|
|
1082
|
+
* @param {Array} items The items in the partial.
|
|
1083
|
+
* @private
|
|
701
1084
|
*/
|
|
702
|
-
|
|
1085
|
+
class Partial {
|
|
1086
|
+
constructor(items) {
|
|
1087
|
+
this.items = items;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
703
1090
|
|
|
704
|
-
|
|
705
|
-
*
|
|
706
|
-
* @param
|
|
707
|
-
* @param
|
|
708
|
-
* @
|
|
1091
|
+
/**
|
|
1092
|
+
* Wrapper for interpolation results with markers.
|
|
1093
|
+
* @param {number} interpolationUid The interpolation UID for markers.
|
|
1094
|
+
* @param {any} result The interpolation result.
|
|
1095
|
+
* @private
|
|
709
1096
|
*/
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
out.push(Component.PLACEHOLDER_EXPRESSION(i));
|
|
717
|
-
}
|
|
718
|
-
return out;
|
|
719
|
-
}, []).join('');
|
|
1097
|
+
class InterpolationWrapper {
|
|
1098
|
+
constructor(interpolationUid, result) {
|
|
1099
|
+
this.interpolationUid = interpolationUid;
|
|
1100
|
+
this.result = result;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
720
1103
|
|
|
721
|
-
|
|
722
|
-
*
|
|
723
|
-
* @
|
|
724
|
-
* @param expressions {array} Array of expressions to replace placeholders.
|
|
725
|
-
* @return {array} Array containing strings and expressions.
|
|
1104
|
+
/**
|
|
1105
|
+
* Manager for delegated events.
|
|
1106
|
+
* @private
|
|
726
1107
|
*/
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
let match;
|
|
733
|
-
// Generate one dimensional array with strings and expressions,
|
|
734
|
-
// so all the components are added as children by the parent component.
|
|
735
|
-
while ((match = regExp.exec(main)) !== null) {
|
|
736
|
-
const before = main.slice(lastIndex, match.index);
|
|
737
|
-
out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
738
|
-
lastIndex = match.index + match[0].length;
|
|
1108
|
+
class EventsManager {
|
|
1109
|
+
constructor() {
|
|
1110
|
+
this.listeners = [];
|
|
1111
|
+
this.types = new Set();
|
|
1112
|
+
this.previousSize = 0;
|
|
739
1113
|
}
|
|
740
|
-
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
741
1114
|
|
|
742
|
-
|
|
743
|
-
|
|
1115
|
+
/**
|
|
1116
|
+
* Add a listener to the events manager.
|
|
1117
|
+
* @param {Function} listener The listener to add.
|
|
1118
|
+
* @param {string} type The type of event.
|
|
1119
|
+
* @return {number} The index of the listener.
|
|
1120
|
+
*/
|
|
1121
|
+
addListener(listener, type) {
|
|
1122
|
+
this.types.add(type);
|
|
1123
|
+
this.listeners.push(listener);
|
|
1124
|
+
return this.listeners.length - 1;
|
|
1125
|
+
}
|
|
744
1126
|
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
* @property {object} attributes Attributes.
|
|
753
|
-
*/
|
|
754
|
-
const expandAttributes = (attributes, getExpressionResult) => {
|
|
755
|
-
const out = attributes.reduce((out, pair) => {
|
|
756
|
-
const attribute = getExpressionResult(pair[0]);
|
|
757
|
-
// Attribute without value.
|
|
758
|
-
if (pair.length === 1) {
|
|
759
|
-
if (typeof attribute === 'object') {
|
|
760
|
-
// Expand objects as attributes.
|
|
761
|
-
out.all = Object.assign(out.all, attribute);
|
|
762
|
-
} else if (typeof attribute === 'string') {
|
|
763
|
-
// Treat as boolean.
|
|
764
|
-
out.all[attribute] = true;
|
|
765
|
-
}
|
|
766
|
-
} else {
|
|
767
|
-
// Attribute with value.
|
|
768
|
-
const value = getExpressionResult(pair[1]);
|
|
769
|
-
out.all[attribute] = value;
|
|
770
|
-
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Reset the events manager.
|
|
1129
|
+
*/
|
|
1130
|
+
reset() {
|
|
1131
|
+
this.listeners = [];
|
|
1132
|
+
this.previousSize = this.types.size;
|
|
1133
|
+
}
|
|
771
1134
|
|
|
772
|
-
|
|
773
|
-
|
|
1135
|
+
/**
|
|
1136
|
+
* Check if there are pending types.
|
|
1137
|
+
* @return {boolean} True if there are pending types, false otherwise.
|
|
1138
|
+
*/
|
|
1139
|
+
hasPendingTypes() {
|
|
1140
|
+
return this.types.size > this.previousSize;
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
774
1143
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
} else {
|
|
782
|
-
// Add attribute.
|
|
783
|
-
out.attributes[key] = out.all[key];
|
|
784
|
-
}
|
|
785
|
-
});
|
|
1144
|
+
/**
|
|
1145
|
+
* Manager for component position tracking and recycling.
|
|
1146
|
+
* @private
|
|
1147
|
+
*/
|
|
1148
|
+
class PathManager {
|
|
1149
|
+
constructor() {}
|
|
786
1150
|
|
|
787
|
-
|
|
788
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* Reset before render.
|
|
1153
|
+
*/
|
|
1154
|
+
reset() {
|
|
1155
|
+
this.paused = 0;
|
|
1156
|
+
this.previous = this.tracked || new Map();
|
|
1157
|
+
this.tracked = new Map();
|
|
1158
|
+
this.positionStack = [0];
|
|
1159
|
+
}
|
|
789
1160
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
* @param main {string} The main template.
|
|
797
|
-
* @return {string} The template with components tags replaced by expressions
|
|
798
|
-
* placeholders.
|
|
799
|
-
*/
|
|
800
|
-
const expandComponents = (main, expressions) => {
|
|
801
|
-
const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
|
|
802
|
-
// Match component tags.
|
|
803
|
-
return main.replace(
|
|
804
|
-
new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
|
|
805
|
-
function() {
|
|
806
|
-
const { tag, attributes, inner, close, raw } = parseMatch(arguments, expressions);
|
|
807
|
-
// No component found.
|
|
808
|
-
if (!(tag.prototype instanceof Component)) return raw;
|
|
1161
|
+
/**
|
|
1162
|
+
* Push position to stack.
|
|
1163
|
+
*/
|
|
1164
|
+
push() {
|
|
1165
|
+
this.positionStack.push(0);
|
|
1166
|
+
}
|
|
809
1167
|
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1168
|
+
/**
|
|
1169
|
+
* Pop position from stack.
|
|
1170
|
+
*/
|
|
1171
|
+
pop() {
|
|
1172
|
+
this.positionStack.pop();
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* Increment position.
|
|
1177
|
+
*/
|
|
1178
|
+
increment() {
|
|
1179
|
+
this.positionStack[this.positionStack.length - 1]++;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Pause tracking.
|
|
1184
|
+
*/
|
|
1185
|
+
pause() {
|
|
1186
|
+
this.paused++;
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Resume tracking.
|
|
1191
|
+
*/
|
|
1192
|
+
resume() {
|
|
1193
|
+
this.paused--;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Get current path as array.
|
|
1198
|
+
* @return {string} Current position path.
|
|
1199
|
+
*/
|
|
1200
|
+
getPath() {
|
|
1201
|
+
return this.positionStack.join('-');
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Track component at current path.
|
|
1206
|
+
* @param {Component} component The component to track.
|
|
1207
|
+
* @return {Component} The component.
|
|
1208
|
+
*/
|
|
1209
|
+
track(component) {
|
|
1210
|
+
if (this.paused === 0) {
|
|
1211
|
+
this.tracked.set(
|
|
1212
|
+
this.getPath(),
|
|
1213
|
+
component
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
return component;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
/**
|
|
1221
|
+
* Find recyclable component by path and type.
|
|
1222
|
+
* @param {Function} constructor The component constructor.
|
|
1223
|
+
* @return {Component|null} The recyclable component or null.
|
|
1224
|
+
*/
|
|
1225
|
+
findRecyclable(constructor) {
|
|
1226
|
+
const prev = this.previous.get(this.getPath());
|
|
1227
|
+
return prev && prev.constructor === constructor && !prev.key ? prev : null;
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Get difference between current and previous attributes.
|
|
1233
|
+
* @param {Object} attributes Current attributes object.
|
|
1234
|
+
* @param {Object} previous Previous attributes object.
|
|
1235
|
+
* @return {Object} Object with add and remove properties.
|
|
1236
|
+
* @module
|
|
1237
|
+
* @private
|
|
1238
|
+
*/
|
|
1239
|
+
function getAttributesDiff(attributes, previous = {}) {
|
|
1240
|
+
const add = {};
|
|
1241
|
+
const remove = [];
|
|
1242
|
+
// Find attributes to add/update.
|
|
1243
|
+
Object.keys(attributes).forEach(key => {
|
|
1244
|
+
let value = attributes[key];
|
|
1245
|
+
|
|
1246
|
+
if (value === true) {
|
|
1247
|
+
add[key] = '';
|
|
1248
|
+
} else if (value !== false) {
|
|
1249
|
+
if (value === null || typeof value === 'undefined') value = '';
|
|
1250
|
+
add[key] = value;
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
// Find attributes to remove.
|
|
1254
|
+
Object.keys(previous).forEach(key => {
|
|
1255
|
+
if (!(key in attributes) || ((previous[key] !== attributes[key]) && attributes[key] === false)) {
|
|
1256
|
+
remove.push(key);
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
1259
|
+
|
|
1260
|
+
return { add, remove };
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
const SYNC_PROPS$1 = ['value', 'checked', 'selected'];
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Element reference for managing DOM element attributes.
|
|
1267
|
+
* @param {Object} options The options object.
|
|
1268
|
+
* @param {Function} options.getSelector Function that returns the CSS selector for the element.
|
|
1269
|
+
* @param {Function} options.getAttributes Function that returns the attributes object for the element.
|
|
1270
|
+
* @private
|
|
1271
|
+
*/
|
|
1272
|
+
class Element {
|
|
1273
|
+
constructor(options) {
|
|
1274
|
+
this.getSelector = options.getSelector;
|
|
1275
|
+
this.getAttributes = options.getAttributes;
|
|
1276
|
+
this.previousAttributes = {};
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* Attach the element reference to a DOM element.
|
|
1281
|
+
* @param {Node} parent The parent node to search in.
|
|
1282
|
+
*/
|
|
1283
|
+
hydrate(parent) {
|
|
1284
|
+
this.ref = parent.querySelector(this.getSelector());
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* Update the element's attributes based on the difference with previous attributes.
|
|
1289
|
+
*/
|
|
1290
|
+
update() {
|
|
1291
|
+
// Attributes diff.
|
|
1292
|
+
const attributes = this.getAttributes();
|
|
1293
|
+
const { remove, add } = getAttributesDiff(attributes, this.previousAttributes);
|
|
1294
|
+
// Store previous attributes.
|
|
1295
|
+
this.previousAttributes = attributes;
|
|
1296
|
+
// Remove attributes first so later `setAttribute` overrides if needed.
|
|
1297
|
+
remove.forEach(attr => {
|
|
1298
|
+
this.ref.removeAttribute(attr);
|
|
1299
|
+
if (SYNC_PROPS$1.includes(attr) && attr in this.ref) {
|
|
1300
|
+
// Reset property to default.
|
|
1301
|
+
this.ref[attr] = attr === 'value' ? '' : false;
|
|
1302
|
+
}
|
|
1303
|
+
});
|
|
1304
|
+
// Add / update attributes.
|
|
1305
|
+
Object.keys(add).forEach(attr => {
|
|
1306
|
+
const value = add[attr];
|
|
1307
|
+
this.ref.setAttribute(attr, value);
|
|
1308
|
+
if (SYNC_PROPS$1.includes(attr) && attr in this.ref) {
|
|
1309
|
+
this.ref[attr] = attr === 'value' ? value : value !== false && value !== 'false';
|
|
1310
|
+
}
|
|
1311
|
+
});
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
/**
|
|
1316
|
+
* Properties that should be mirrored from source to target.
|
|
1317
|
+
* @type {string[]}
|
|
1318
|
+
* @private
|
|
1319
|
+
*/
|
|
1320
|
+
const SYNC_PROPS = ['value', 'checked', 'selected'];
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Compares two elements to see if their child nodes are structurally equal.
|
|
1324
|
+
* @param {Element} a First element to compare.
|
|
1325
|
+
* @param {Element} b Second element to compare.
|
|
1326
|
+
* @return {boolean} True if all child nodes are deeply equal.
|
|
1327
|
+
* @private
|
|
1328
|
+
*/
|
|
1329
|
+
const areChildNodesEqual = (a, b) => {
|
|
1330
|
+
const aChildren = a.childNodes;
|
|
1331
|
+
const bChildren = b.childNodes;
|
|
1332
|
+
const aLength = aChildren.length;
|
|
1333
|
+
|
|
1334
|
+
if (aLength !== bChildren.length) return false;
|
|
1335
|
+
|
|
1336
|
+
for (let i = 0; i < aLength; i++) {
|
|
1337
|
+
if (!aChildren[i].isEqualNode(bChildren[i])) {
|
|
1338
|
+
return false;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
return true;
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Synchronizes attributes and key DOM properties (value, checked, selected).
|
|
1346
|
+
* Assumes tagName is already the same.
|
|
1347
|
+
* @param {Element} targetEl Target DOM element to update.
|
|
1348
|
+
* @param {Element} sourceEl Source DOM element to copy attributes from.
|
|
1349
|
+
* @private
|
|
1350
|
+
*/
|
|
1351
|
+
const syncAttributes = (targetEl, sourceEl) => {
|
|
1352
|
+
// Add, update and remove attributes.
|
|
1353
|
+
const srcAttrs = sourceEl.attributes;
|
|
1354
|
+
const tgtAttrs = targetEl.attributes;
|
|
1355
|
+
const srcAttrNames = new Set();
|
|
1356
|
+
|
|
1357
|
+
for (let i = 0, l = srcAttrs.length; i < l; i++) {
|
|
1358
|
+
const { name, value } = srcAttrs[i];
|
|
1359
|
+
srcAttrNames.add(name);
|
|
1360
|
+
if (targetEl.getAttribute(name) !== value) targetEl.setAttribute(name, value);
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
for (let i = tgtAttrs.length - 1; i >= 0; i--) {
|
|
1364
|
+
const { name } = tgtAttrs[i];
|
|
1365
|
+
if (!srcAttrNames.has(name)) targetEl.removeAttribute(name);
|
|
1366
|
+
}
|
|
1367
|
+
// Sync key DOM properties.
|
|
1368
|
+
for (let i = 0, l = SYNC_PROPS.length; i < l; i++) {
|
|
1369
|
+
const prop = SYNC_PROPS[i];
|
|
1370
|
+
if (prop in targetEl && targetEl[prop] !== sourceEl[prop]) {
|
|
1371
|
+
targetEl[prop] = sourceEl[prop];
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
};
|
|
1375
|
+
|
|
1376
|
+
/**
|
|
1377
|
+
* Replaces all child nodes of targetEl with those from sourceEl.
|
|
1378
|
+
* Moves the nodes without cloning.
|
|
1379
|
+
* @param {Element} targetEl The element whose children will be replaced.
|
|
1380
|
+
* @param {Element} sourceEl The element providing new children.
|
|
1381
|
+
* @module
|
|
1382
|
+
* @private
|
|
1383
|
+
*/
|
|
1384
|
+
const syncNodeContent = (targetEl, sourceEl) => {
|
|
1385
|
+
const children = Array.from(sourceEl.childNodes);
|
|
1386
|
+
targetEl.replaceChildren(...children);
|
|
1387
|
+
};
|
|
1388
|
+
|
|
1389
|
+
/**
|
|
1390
|
+
* Synchronizes the contents of one DOM node to match another.
|
|
1391
|
+
* It performs a shallow sync: tag name, attributes, properties, and child nodes.
|
|
1392
|
+
* If tag names or node types differ, it replaces the node entirely.
|
|
1393
|
+
* @param {Node} targetNode The node currently in the DOM to be updated.
|
|
1394
|
+
* @param {Node} sourceNode The reference node to sync from (not in the DOM).
|
|
1395
|
+
* @private
|
|
1396
|
+
*/
|
|
1397
|
+
function syncNode(targetNode, sourceNode) {
|
|
1398
|
+
// Replace if node types are different (e.g. element vs text).
|
|
1399
|
+
if (targetNode.nodeType !== sourceNode.nodeType) {
|
|
1400
|
+
targetNode.replaceWith(sourceNode);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
// Text node: sync value.
|
|
1404
|
+
if (targetNode.nodeType === Node.TEXT_NODE) {
|
|
1405
|
+
if (targetNode.nodeValue !== sourceNode.nodeValue) {
|
|
1406
|
+
targetNode.nodeValue = sourceNode.nodeValue;
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
// Element node: check tag.
|
|
1411
|
+
if (targetNode.tagName !== sourceNode.tagName) {
|
|
1412
|
+
targetNode.replaceWith(sourceNode);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
// Sync attributes and DOM properties.
|
|
1416
|
+
syncAttributes(targetNode, sourceNode);
|
|
1417
|
+
// Sync child nodes if necessary.
|
|
1418
|
+
if (!areChildNodesEqual(targetNode, sourceNode)) {
|
|
1419
|
+
syncNodeContent(targetNode, sourceNode);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Finds the first comment node whose text matches exactly the given `text`.
|
|
1425
|
+
* Uses manual DOM traversal. Skips entire subtrees if `shouldSkip(element)` returns true.
|
|
1426
|
+
* Starts searching from `startNode` if provided, otherwise from `root.firstChild`.
|
|
1427
|
+
* @param {Node} root - Root node or fragment that limits the search scope.
|
|
1428
|
+
* @param {string} text - Exact comment text to match.
|
|
1429
|
+
* @param {Function} [shouldSkip] - Function that receives an element and returns true if its subtree should be skipped.
|
|
1430
|
+
* @param {Node} [startNode] - Node to start searching from (defaults to root.firstChild).
|
|
1431
|
+
* @return {Comment|null} The first matching comment node, or null if not found.
|
|
1432
|
+
* @module
|
|
1433
|
+
* @private
|
|
1434
|
+
*/
|
|
1435
|
+
function findComment(
|
|
1436
|
+
root,
|
|
1437
|
+
text,
|
|
1438
|
+
shouldSkip = () => false,
|
|
1439
|
+
startNode
|
|
1440
|
+
) {
|
|
1441
|
+
let node = startNode || root.firstChild;
|
|
1442
|
+
|
|
1443
|
+
while (node) {
|
|
1444
|
+
// Check if current node is a comment with matching text.
|
|
1445
|
+
if (node.nodeType === Node.COMMENT_NODE && node.data.trim() === text) {
|
|
1446
|
+
return node;
|
|
1447
|
+
}
|
|
1448
|
+
// Descend into children if allowed and present.
|
|
1449
|
+
if (node.nodeType === Node.ELEMENT_NODE && !shouldSkip(node) && node.firstChild) {
|
|
1450
|
+
node = node.firstChild;
|
|
1451
|
+
continue;
|
|
1452
|
+
}
|
|
1453
|
+
// Move to next sibling, or climb up until a sibling is found.
|
|
1454
|
+
while (node && !node.nextSibling) {
|
|
1455
|
+
node = node.parentNode;
|
|
1456
|
+
if (!node || node === root) return null;
|
|
1457
|
+
}
|
|
1458
|
+
if (node) node = node.nextSibling;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
return null;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
/**
|
|
1465
|
+
* Interpolation reference for managing dynamic content between comment markers.
|
|
1466
|
+
* Handles the lifecycle of content that can change between renders, including
|
|
1467
|
+
* component recycling and DOM synchronization.
|
|
1468
|
+
* @param {Object} options The options object.
|
|
1469
|
+
* @param {Function} options.getStart Function that returns the start comment marker text.
|
|
1470
|
+
* @param {Function} options.getEnd Function that returns the end comment marker text.
|
|
1471
|
+
* @param {any} options.expression The expression to be evaluated for the interpolation.
|
|
1472
|
+
* @param {Function} options.isComponent Function that checks if an element is a component root element.
|
|
1473
|
+
* @param {Function} options.isElement Function that checks if an element has the Rasti data attribute.
|
|
1474
|
+
* @private
|
|
1475
|
+
*/
|
|
1476
|
+
class Interpolation {
|
|
1477
|
+
constructor(options) {
|
|
1478
|
+
this.getStart = options.getStart;
|
|
1479
|
+
this.getEnd = options.getEnd;
|
|
1480
|
+
this.expression = options.expression;
|
|
1481
|
+
this.isComponent = options.isComponent;
|
|
1482
|
+
this.isElement = options.isElement;
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
/**
|
|
1486
|
+
* Attach the interpolation reference to comment markers in the DOM.
|
|
1487
|
+
* Searches for start and end comment markers, skipping component subtrees.
|
|
1488
|
+
* @param {Node} parent The parent node to search in.
|
|
1489
|
+
*/
|
|
1490
|
+
hydrate(parent) {
|
|
1491
|
+
const startComment = findComment(parent, this.getStart(), this.isComponent);
|
|
1492
|
+
const endComment = findComment(parent, this.getEnd(), this.isComponent, startComment);
|
|
1493
|
+
|
|
1494
|
+
this.ref = [
|
|
1495
|
+
startComment,
|
|
1496
|
+
endComment
|
|
1497
|
+
];
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Update the interpolation content with a new fragment.
|
|
1502
|
+
* Optimizes updates by syncing single non-component elements or replacing content entirely.
|
|
1503
|
+
* @param {DocumentFragment} fragment The new content fragment to insert.
|
|
1504
|
+
*/
|
|
1505
|
+
update(fragment) {
|
|
1506
|
+
const [startComment, endComment] = this.ref;
|
|
1507
|
+
|
|
1508
|
+
const currentFirstElement = startComment.nextSibling;
|
|
1509
|
+
const currentSingleChildElement = currentFirstElement.nextSibling === endComment;
|
|
1510
|
+
const currentEmpty = currentFirstElement == endComment;
|
|
1511
|
+
const fragmentChildren = fragment.children;
|
|
1512
|
+
|
|
1513
|
+
if (currentSingleChildElement && fragmentChildren.length === 1 && !this.isElement(currentFirstElement)) {
|
|
1514
|
+
// There is a single child element that is not a component's root element. Sync node attributes and content.
|
|
1515
|
+
syncNode(currentFirstElement, fragmentChildren[0]);
|
|
1516
|
+
} else if (currentEmpty) {
|
|
1517
|
+
// Interpolation is empty. Insert the fragment.
|
|
1518
|
+
endComment.parentNode.insertBefore(fragment, endComment);
|
|
1519
|
+
} else {
|
|
1520
|
+
// Interpolation is not empty. Replace the interpolation content.
|
|
1521
|
+
const range = document.createRange();
|
|
1522
|
+
range.setStartAfter(startComment);
|
|
1523
|
+
range.setEndBefore(endComment);
|
|
1524
|
+
range.deleteContents();
|
|
1525
|
+
range.insertNode(fragment);
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* Flatten an array recursively.
|
|
1532
|
+
* @param {Array} arr Array to flat recursively
|
|
1533
|
+
* @return {Array} Flat array
|
|
1534
|
+
* @module
|
|
1535
|
+
* @private
|
|
1536
|
+
*/
|
|
1537
|
+
const deepFlat = (arr) => arr.reduce((acc, val) => {
|
|
1538
|
+
if (Array.isArray(val)) acc.push(...deepFlat(val));
|
|
1539
|
+
else acc.push(val);
|
|
1540
|
+
return acc;
|
|
1541
|
+
}, []);
|
|
1542
|
+
|
|
1543
|
+
/**
|
|
1544
|
+
* Parse HTML string to a DocumentFragment.
|
|
1545
|
+
* @param {string} html The HTML string to parse.
|
|
1546
|
+
* @return {DocumentFragment} The parsed DocumentFragment.
|
|
1547
|
+
* @module
|
|
1548
|
+
* @private
|
|
1549
|
+
*/
|
|
1550
|
+
function parseHTML(html) {
|
|
1551
|
+
const fragment = document.createElement('template');
|
|
1552
|
+
fragment.innerHTML = `${html}`.trim();
|
|
1553
|
+
return fragment.content;
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
/**
|
|
1557
|
+
* Generate HTML string from attributes object.
|
|
1558
|
+
* @param {Object} attributes Object containing attribute names and values.
|
|
1559
|
+
* @return {string} HTML string of attributes.
|
|
1560
|
+
* @module
|
|
1561
|
+
* @private
|
|
1562
|
+
*/
|
|
1563
|
+
function getAttributesHTML(attributes) {
|
|
1564
|
+
const html = [];
|
|
1565
|
+
|
|
1566
|
+
Object.keys(attributes).forEach(key => {
|
|
1567
|
+
let value = attributes[key];
|
|
1568
|
+
|
|
1569
|
+
if (value === true) {
|
|
1570
|
+
html.push(key);
|
|
1571
|
+
} else if (value !== false) {
|
|
1572
|
+
if (value === null || typeof value === 'undefined') value = '';
|
|
1573
|
+
html.push(`${key}="${value}"`);
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
|
|
1577
|
+
return html.join(' ');
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/**
|
|
1581
|
+
* Same as getResult, but pass context as argument to the expression.
|
|
1582
|
+
* Used to evaluate expressions in the context of a component.
|
|
1583
|
+
* @param {any} expression The expression to be evaluated.
|
|
1584
|
+
* @param {any} context The context to call the expression with.
|
|
1585
|
+
* @return {any} The result of the evaluated expression.
|
|
1586
|
+
* @private
|
|
1587
|
+
*/
|
|
1588
|
+
const getExpressionResult = (expression, context) => getResult(expression, context, context);
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Check if an element is a component root element.
|
|
1592
|
+
* Component root elements have the data attribute ending with '-1'.
|
|
1593
|
+
* @param {Element} el The element to check.
|
|
1594
|
+
* @return {boolean} True if the element is a component root element.
|
|
1595
|
+
* @private
|
|
1596
|
+
*/
|
|
1597
|
+
const isComponent = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT) && el.getAttribute(Component.ATTRIBUTE_ELEMENT).endsWith('-1');
|
|
1598
|
+
|
|
1599
|
+
/**
|
|
1600
|
+
* Check if an element has the Rasti data attribute.
|
|
1601
|
+
* This includes both component root elements and regular tracked elements.
|
|
1602
|
+
* @param {Element} el The element to check.
|
|
1603
|
+
* @return {boolean} True if the element has the data attribute.
|
|
1604
|
+
* @private
|
|
1605
|
+
*/
|
|
1606
|
+
const isElement = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT);
|
|
1607
|
+
|
|
1608
|
+
/**
|
|
1609
|
+
* Generate string with placeholders for interpolated expressions.
|
|
1610
|
+
* @param {Array<string>} strings Array of strings.
|
|
1611
|
+
* @param {Array<any>} expressions Array of expressions.
|
|
1612
|
+
* @return {string} String with placeholders.
|
|
1613
|
+
* @private
|
|
1614
|
+
*/
|
|
1615
|
+
const addPlaceholders = (strings, expressions) =>
|
|
1616
|
+
strings.reduce((out, string, i) => {
|
|
1617
|
+
// Add string part.
|
|
1618
|
+
out.push(string);
|
|
1619
|
+
// Add expression placeholders.
|
|
1620
|
+
if (typeof expressions[i] !== 'undefined') {
|
|
1621
|
+
out.push(Component.PLACEHOLDER(i));
|
|
1622
|
+
}
|
|
1623
|
+
return out;
|
|
1624
|
+
}, []).join('');
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* Generate one dimensional array with strings and expressions.
|
|
1628
|
+
* @param main {string} The main template containing placeholders.
|
|
1629
|
+
* @param {Array<any>} expressions Array of expressions to replace placeholders.
|
|
1630
|
+
* @return {array} Array containing strings and expressions.
|
|
1631
|
+
* @private
|
|
1632
|
+
*/
|
|
1633
|
+
const splitPlaceholders = (main, expressions) => {
|
|
1634
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1635
|
+
const regExp = new RegExp(`${PH}`, 'g');
|
|
1636
|
+
const out = [];
|
|
1637
|
+
let lastIndex = 0;
|
|
1638
|
+
let match;
|
|
1639
|
+
// Generate one dimensional array with strings and expressions,
|
|
1640
|
+
// so all the components are added as children by the parent component.
|
|
1641
|
+
while ((match = regExp.exec(main)) !== null) {
|
|
1642
|
+
const before = main.slice(lastIndex, match.index);
|
|
1643
|
+
out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
1644
|
+
lastIndex = match.index + match[0].length;
|
|
1645
|
+
}
|
|
1646
|
+
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
1647
|
+
|
|
1648
|
+
return out;
|
|
1649
|
+
};
|
|
1650
|
+
|
|
1651
|
+
/**
|
|
1652
|
+
* Expand attributes.
|
|
1653
|
+
* @param {Array<Array<any>>} attributes Array of attributes as key, value pairs.
|
|
1654
|
+
* @param {Function} getExpressionResult Function to render expressions.
|
|
1655
|
+
* @return {object}
|
|
1656
|
+
* @property {object} all All attributes.
|
|
1657
|
+
* @property {object} events Event listeners.
|
|
1658
|
+
* @property {object} attributes Attributes.
|
|
1659
|
+
* @private
|
|
1660
|
+
*/
|
|
1661
|
+
const expandAttributes = (attributes, getExpressionResult) => attributes.reduce((out, pair) => {
|
|
1662
|
+
const attribute = getExpressionResult(pair[0]);
|
|
1663
|
+
// Attribute without value.
|
|
1664
|
+
if (pair.length === 1) {
|
|
1665
|
+
if (typeof attribute === 'object') {
|
|
1666
|
+
// Expand objects as attributes.
|
|
1667
|
+
out = Object.assign(out, attribute);
|
|
1668
|
+
} else if (typeof attribute === 'string') {
|
|
1669
|
+
// Treat as boolean.
|
|
1670
|
+
out[attribute] = true;
|
|
1671
|
+
}
|
|
1672
|
+
} else {
|
|
1673
|
+
// Attribute with value.
|
|
1674
|
+
const value = pair[2] ? getExpressionResult(pair[1]) : pair[1];
|
|
1675
|
+
out[attribute] = value;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
return out;
|
|
1679
|
+
}, {});
|
|
1680
|
+
|
|
1681
|
+
/**
|
|
1682
|
+
* Expand events.
|
|
1683
|
+
* @param {object} attributes Attributes object.
|
|
1684
|
+
* @param {EventsManager} eventsManager Events manager.
|
|
1685
|
+
* @return {object} Attributes object.
|
|
1686
|
+
* @private
|
|
1687
|
+
*/
|
|
1688
|
+
const expandEvents = (attributes, eventsManager) => {
|
|
1689
|
+
const out = {};
|
|
1690
|
+
Object.keys(attributes).forEach(key => {
|
|
1691
|
+
// Check if key is an event listener.
|
|
1692
|
+
const match = key.match(/on(([A-Z]{1}[a-z]+)+)/);
|
|
1693
|
+
|
|
1694
|
+
if (match && match[1]) {
|
|
1695
|
+
const type = match[1].toLowerCase();
|
|
1696
|
+
const listener = attributes[key];
|
|
1697
|
+
if (listener) {
|
|
1698
|
+
const index = eventsManager.addListener(listener, type);
|
|
1699
|
+
// Add event listener index.
|
|
1700
|
+
out[Component.ATTRIBUTE_EVENT(type)] = index;
|
|
1701
|
+
}
|
|
1702
|
+
} else {
|
|
1703
|
+
// Add attribute.
|
|
1704
|
+
out[key] = attributes[key];
|
|
1705
|
+
}
|
|
1706
|
+
});
|
|
1707
|
+
return out;
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* Replace component tags with expressions.
|
|
1712
|
+
* `<${Component} />` or `<${Component}></${Component}>` will be replaced
|
|
1713
|
+
* by a function that mounts the component.
|
|
1714
|
+
* Returns the template with component tags replaced by expressions placeholders
|
|
1715
|
+
* modifies the expressions array adding the mount functions.
|
|
1716
|
+
* @param main {string} The main template.
|
|
1717
|
+
* @param {Array<any>} expressions Array of expressions.
|
|
1718
|
+
* @return {string} The template with components tags replaced by expressions
|
|
1719
|
+
* placeholders.
|
|
1720
|
+
* @private
|
|
1721
|
+
*/
|
|
1722
|
+
const expandComponents = (main, expressions) => {
|
|
1723
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1724
|
+
// Match component tags.
|
|
1725
|
+
return main.replace(
|
|
1726
|
+
new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
|
|
1727
|
+
(match, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx, selfClosingTag, selfClosingIdx, selfClosingAttrs) => {
|
|
1728
|
+
let tag, close, attributesStr;
|
|
1729
|
+
|
|
1730
|
+
if (openTag) {
|
|
1731
|
+
tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
|
|
1732
|
+
close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
|
|
1733
|
+
attributesStr = nonVoidAttrs;
|
|
1734
|
+
} else {
|
|
1735
|
+
tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
|
|
1736
|
+
attributesStr = selfClosingAttrs;
|
|
1737
|
+
}
|
|
1738
|
+
// No component found.
|
|
1739
|
+
if (!(tag.prototype instanceof Component)) return match;
|
|
1740
|
+
|
|
1741
|
+
let innerList;
|
|
1742
|
+
// Non void component.
|
|
1743
|
+
if (close) {
|
|
1744
|
+
// Close component tag must match open component tag.
|
|
1745
|
+
if (tag !== close) return match;
|
|
1746
|
+
// Process inner content same way as partial().
|
|
815
1747
|
// Recursively expand inner components.
|
|
816
|
-
const
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1748
|
+
const innerTemplate = expandComponents(inner, expressions);
|
|
1749
|
+
// Parse partial elements to handle dynamic attributes and events.
|
|
1750
|
+
const parsedInner = parsePartialElements(innerTemplate, expressions);
|
|
1751
|
+
// Split into items.
|
|
1752
|
+
innerList = splitPlaceholders(parsedInner, expressions);
|
|
821
1753
|
}
|
|
1754
|
+
// Parse attributes.
|
|
1755
|
+
const attributes = parseAttributes(attributesStr, expressions);
|
|
822
1756
|
// Create mount function.
|
|
823
1757
|
const mount = function() {
|
|
824
|
-
const options = expandAttributes(attributes, value => getExpressionResult(value, this))
|
|
825
|
-
// Add renderChildren function to options.
|
|
826
|
-
if (
|
|
1758
|
+
const options = expandAttributes(attributes, value => getExpressionResult(value, this));
|
|
1759
|
+
// Add `renderChildren` function to options.
|
|
1760
|
+
if (innerList) {
|
|
1761
|
+
// Evaluate items in parent context and create Partial.
|
|
1762
|
+
options.renderChildren = () => new Partial(innerList.map(item => getExpressionResult(item, this)));
|
|
1763
|
+
}
|
|
827
1764
|
// Mount component.
|
|
828
1765
|
return tag.mount(options);
|
|
829
1766
|
};
|
|
830
1767
|
// Add mount function to expression.
|
|
831
1768
|
expressions.push(mount);
|
|
832
1769
|
// Replace whole string with expression placeholder.
|
|
833
|
-
return Component.
|
|
1770
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
834
1771
|
}
|
|
835
1772
|
);
|
|
836
1773
|
};
|
|
837
1774
|
|
|
838
|
-
|
|
839
|
-
*
|
|
840
|
-
* @param
|
|
841
|
-
* @
|
|
842
|
-
* @
|
|
843
|
-
* @
|
|
844
|
-
* @property {string} close The closing tag.
|
|
845
|
-
* @property {array} attributes Array of attributes as key, value pairs.
|
|
846
|
-
* @property {string} raw The whole match.
|
|
1775
|
+
/**
|
|
1776
|
+
* Replace elements in template.
|
|
1777
|
+
* @param {string} template Template string.
|
|
1778
|
+
* @param {Function} replacer Replacer function.
|
|
1779
|
+
* @return {string} Template string with replaced elements.
|
|
1780
|
+
* @private
|
|
847
1781
|
*/
|
|
848
|
-
const
|
|
849
|
-
const PH = Component.
|
|
1782
|
+
const replaceElements = (template, replacer) => {
|
|
1783
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1784
|
+
return template.replace(
|
|
1785
|
+
new RegExp(`<(${PH}|[a-z]+[1-6]?)(?:\\s*)((?:"[^"]*"|'[^']*'|[^>])*)(/?>)`, 'gi'),
|
|
1786
|
+
replacer
|
|
1787
|
+
);
|
|
1788
|
+
};
|
|
850
1789
|
|
|
851
|
-
|
|
852
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* Parse all HTML elements in template and extract their attributes.
|
|
1792
|
+
* @param {string} template Template string with placeholders.
|
|
1793
|
+
* @param {Array} expressions Array of expressions.
|
|
1794
|
+
* @param {Array} elements Array to store element references.
|
|
1795
|
+
* @return {string} Template with parsed attributes.
|
|
1796
|
+
* @throws {SyntaxError} If the template does not have a single root element or is a container component.
|
|
1797
|
+
* @private
|
|
1798
|
+
*/
|
|
1799
|
+
const parseElements = (template, expressions, elements) => {
|
|
1800
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1801
|
+
// Check if template is a container (single placeholder, no tag).
|
|
1802
|
+
const containerMatch = template.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
1803
|
+
if (containerMatch) return template;
|
|
1804
|
+
// Validate that template has a root element.
|
|
1805
|
+
const rootElementMatch = template.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`));
|
|
1806
|
+
if (!rootElementMatch) throw new SyntaxError(`Template must have a single root element or be a container component: "${template.trim()}"`);
|
|
1807
|
+
|
|
1808
|
+
let elementUid = 0;
|
|
1809
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
1810
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
1811
|
+
const isRoot = elementUid === 0;
|
|
1812
|
+
const currentElementUid = ++elementUid;
|
|
1813
|
+
// If there are no dynamic attributes, return original match.
|
|
1814
|
+
if (!isRoot && !attributesStr.match(new RegExp(PH))) {
|
|
1815
|
+
return match;
|
|
1816
|
+
}
|
|
1817
|
+
// Parse attributes.
|
|
1818
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
1819
|
+
// Create element reference.
|
|
1820
|
+
const generateElementUid = componentUid => `${componentUid}-${currentElementUid}`;
|
|
1821
|
+
// Create function that returns attributes object.
|
|
1822
|
+
const getAttributes = function() {
|
|
1823
|
+
// Expand attributes and events.
|
|
1824
|
+
const attributes = expandEvents(
|
|
1825
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
1826
|
+
this.eventsManager
|
|
1827
|
+
);
|
|
1828
|
+
// Extend template attributes with `options.attributes`.
|
|
1829
|
+
if (isRoot && this.attributes) {
|
|
1830
|
+
Object.assign(attributes, getResult(this.attributes, this));
|
|
1831
|
+
}
|
|
1832
|
+
// Add data attribute for element identification.
|
|
1833
|
+
// First element gets the component uid, others get element uid.
|
|
1834
|
+
attributes[Component.ATTRIBUTE_ELEMENT] = generateElementUid(this.uid);
|
|
1835
|
+
|
|
1836
|
+
return attributes;
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
const getSelector = function() {
|
|
1840
|
+
return `[${Component.ATTRIBUTE_ELEMENT}="${generateElementUid(this.uid)}"]`;
|
|
1841
|
+
};
|
|
1842
|
+
// Add element reference to elements array.
|
|
1843
|
+
elements.push({
|
|
1844
|
+
getSelector,
|
|
1845
|
+
getAttributes,
|
|
1846
|
+
});
|
|
1847
|
+
// Add new expression to expressions array.
|
|
1848
|
+
expressions.push(function() {
|
|
1849
|
+
const attributes = getAttributes.call(this);
|
|
1850
|
+
return Component.markAsSafeHTML(getAttributesHTML(attributes));
|
|
1851
|
+
});
|
|
1852
|
+
// Replace attributes with placeholder.
|
|
1853
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
1854
|
+
// Preserve original tag ending (> or />)
|
|
1855
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
1856
|
+
});
|
|
1857
|
+
};
|
|
853
1858
|
|
|
854
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* Parse elements in partial template.
|
|
1861
|
+
* @param {string} template Template string with placeholders.
|
|
1862
|
+
* @param {Array} expressions Array of expressions.
|
|
1863
|
+
* @return {string} Template with parsed attributes.
|
|
1864
|
+
* @private
|
|
1865
|
+
*/
|
|
1866
|
+
const parsePartialElements = (template, expressions) => {
|
|
1867
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1868
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
1869
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
1870
|
+
// If there are no dynamic attributes, return original match.
|
|
1871
|
+
if (!attributesStr.match(new RegExp(PH))) {
|
|
1872
|
+
return match;
|
|
1873
|
+
}
|
|
1874
|
+
// Parse attributes.
|
|
1875
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
1876
|
+
// Create function that returns attributes object.
|
|
1877
|
+
const getAttributes = function() {
|
|
1878
|
+
const attributes = expandEvents(
|
|
1879
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
1880
|
+
this.eventsManager
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
return attributes;
|
|
1884
|
+
};
|
|
1885
|
+
// Add new expression to expressions array.
|
|
1886
|
+
expressions.push(function() {
|
|
1887
|
+
const attributes = getAttributes.call(this);
|
|
1888
|
+
return Component.markAsSafeHTML(getAttributesHTML(attributes));
|
|
1889
|
+
});
|
|
1890
|
+
// Replace attributes with placeholder.
|
|
1891
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
1892
|
+
// Preserve original tag ending (> or />)
|
|
1893
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
1894
|
+
});
|
|
1895
|
+
};
|
|
855
1896
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
1897
|
+
/**
|
|
1898
|
+
* Parse all interpolations in template text content.
|
|
1899
|
+
* @param {string} template Template string with placeholders.
|
|
1900
|
+
* @param {Array} expressions Array of expressions.
|
|
1901
|
+
* @param {Array} interpolations Array to store interpolation references.
|
|
1902
|
+
* @return {string} Template with interpolation markers.
|
|
1903
|
+
* @private
|
|
1904
|
+
*/
|
|
1905
|
+
const parseInterpolations = (template, expressions, interpolations) => {
|
|
1906
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1907
|
+
let interpolationUid = 0;
|
|
1908
|
+
// Match all expression placeholders.
|
|
1909
|
+
return template.replace(
|
|
1910
|
+
new RegExp(PH, 'g'),
|
|
1911
|
+
function(match, expressionIndex, offset) {
|
|
1912
|
+
// Check if this placeholder is inside an element tag (attribute).
|
|
1913
|
+
// `offset` is the index of the match in the original string.
|
|
1914
|
+
const beforeMatch = template.substring(0, offset);
|
|
1915
|
+
const lastOpenTag = beforeMatch.lastIndexOf('<');
|
|
1916
|
+
const lastCloseTag = beforeMatch.lastIndexOf('>');
|
|
1917
|
+
// If we're inside an element tag, don't process as interpolation.
|
|
1918
|
+
if (lastOpenTag > lastCloseTag) {
|
|
1919
|
+
return match;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const currentInterpolationUid = ++interpolationUid;
|
|
1923
|
+
|
|
1924
|
+
function getStart() {
|
|
1925
|
+
return Component.MARKER_START(`${this.uid}-${currentInterpolationUid}`);
|
|
1926
|
+
}
|
|
1927
|
+
function getEnd() {
|
|
1928
|
+
return Component.MARKER_END(`${this.uid}-${currentInterpolationUid}`);
|
|
1929
|
+
}
|
|
1930
|
+
// Add interpolation reference to interpolations array.
|
|
1931
|
+
interpolations.push({
|
|
1932
|
+
getStart,
|
|
1933
|
+
getEnd,
|
|
1934
|
+
expression : expressions[expressionIndex]
|
|
1935
|
+
});
|
|
1936
|
+
// Add new expression to expressions array.
|
|
1937
|
+
expressions.push(function() {
|
|
1938
|
+
const result = getExpressionResult(expressions[expressionIndex], this);
|
|
1939
|
+
const uid = `${this.uid}-${currentInterpolationUid}`;
|
|
1940
|
+
return new InterpolationWrapper(uid, result);
|
|
1941
|
+
});
|
|
1942
|
+
// Replace with new placeholder.
|
|
1943
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
1944
|
+
}
|
|
1945
|
+
);
|
|
1946
|
+
};
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* Parse attributes string to extract dynamic attributes.
|
|
1950
|
+
* @param {string} attributesStr Attributes string from HTML element.
|
|
1951
|
+
* @param {Array} expressions Array of expressions.
|
|
1952
|
+
* @return {Array} Array of attribute pairs [key, value] or [key, value, hasQuotes].
|
|
1953
|
+
* @private
|
|
1954
|
+
*/
|
|
1955
|
+
const parseAttributes = (attributesStr, expressions) => {
|
|
1956
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1957
|
+
const attributes = [];
|
|
1958
|
+
// Parse attributes string with support for placeholders in both names and values.
|
|
869
1959
|
const regExp = new RegExp(`(${PH}|[\\w-]+)(?:=(["']?)(?:${PH}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`, 'g');
|
|
870
1960
|
|
|
871
1961
|
let attributeMatch;
|
|
872
1962
|
while ((attributeMatch = regExp.exec(attributesStr)) !== null) {
|
|
873
|
-
const [, attribute, attributeIdx
|
|
1963
|
+
const [, attribute, attributeIdx, quotes, valueIdx, value] = attributeMatch;
|
|
874
1964
|
|
|
875
|
-
const attr = typeof attributeIdx !== 'undefined' ? expressions[attributeIdx] : attribute;
|
|
876
|
-
const val = typeof valueIdx !== 'undefined' ? expressions[valueIdx] : value;
|
|
1965
|
+
const attr = typeof attributeIdx !== 'undefined' ? expressions[parseInt(attributeIdx, 10)] : attribute;
|
|
1966
|
+
const val = typeof valueIdx !== 'undefined' ? expressions[parseInt(valueIdx, 10)] : value;
|
|
877
1967
|
|
|
878
1968
|
if (typeof val !== 'undefined') {
|
|
879
|
-
|
|
1969
|
+
attributes.push([attr, val, !!quotes]);
|
|
880
1970
|
} else {
|
|
881
|
-
|
|
1971
|
+
attributes.push([attr]);
|
|
882
1972
|
}
|
|
883
1973
|
}
|
|
884
1974
|
|
|
885
|
-
return
|
|
886
|
-
};
|
|
887
|
-
|
|
888
|
-
/*
|
|
889
|
-
* HTML tags that are self closing.
|
|
890
|
-
*/
|
|
891
|
-
const selfClosingTags = {
|
|
892
|
-
area : true, base : true, br : true, col : true, embed : true, hr : true,
|
|
893
|
-
img : true, input : true, link : true, meta : true, source : true, track : true, wbr : true
|
|
1975
|
+
return attributes;
|
|
894
1976
|
};
|
|
895
1977
|
|
|
896
1978
|
/*
|
|
897
1979
|
* These option keys will be extended on the component instance.
|
|
898
1980
|
*/
|
|
899
|
-
const componentOptions =
|
|
900
|
-
key : true,
|
|
901
|
-
state : true,
|
|
902
|
-
onCreate : true,
|
|
903
|
-
onChange : true,
|
|
904
|
-
onRender : true
|
|
905
|
-
};
|
|
1981
|
+
const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onHydrate', 'onRecycle', 'onUpdate'];
|
|
906
1982
|
|
|
907
1983
|
/**
|
|
908
|
-
*
|
|
909
|
-
* making it simple to add child views and build complex user interfaces.
|
|
910
|
-
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
911
|
-
* guidelines that allow for a more declarative development style.
|
|
912
|
-
* Components are defined with the {@link #module_component_create Component.create} static method, which takes a tagged template string or a function that returns another component.
|
|
913
|
-
* @module
|
|
914
|
-
* @extends Rasti.View
|
|
915
|
-
* @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onRender, onCreate, onChange.
|
|
916
|
-
* @property {string} key A unique key to identify the component. Used to recycle child components.
|
|
917
|
-
* @property {object} model A `Rasti.Model` or any emitter object containing data and business logic. The component will listen to `change` events and call `onChange` lifecycle method.
|
|
918
|
-
* @property {object} state A `Rasti.Model` or any emitter object containing data and business logic, to be used as internal state. The component will listen to `change` events and call `onChange` lifecycle method.
|
|
919
|
-
* @see {@link #module_component_create Component.create}
|
|
920
|
-
* @example
|
|
921
|
-
* import { Component, Model } from 'rasti';
|
|
922
|
-
* // Create Timer component.
|
|
923
|
-
* const Timer = Component.create`
|
|
924
|
-
* <div>
|
|
925
|
-
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
926
|
-
* </div>
|
|
927
|
-
* `;
|
|
928
|
-
* // Create model to store seconds.
|
|
929
|
-
* const model = new Model({ seconds: 0 });
|
|
930
|
-
* // Mount timer on body.
|
|
931
|
-
* Timer.mount({ model }, document.body);
|
|
932
|
-
* // Increment `model.seconds` every second.
|
|
933
|
-
* setInterval(() => model.seconds++, 1000);
|
|
1984
|
+
* @lends module:Component
|
|
934
1985
|
*/
|
|
935
1986
|
class Component extends View {
|
|
936
1987
|
constructor(options = {}) {
|
|
937
1988
|
super(...arguments);
|
|
938
|
-
|
|
1989
|
+
this.componentOptions = [];
|
|
1990
|
+
// Extend "this" with options.
|
|
1991
|
+
componentOptions.forEach(key => {
|
|
1992
|
+
if (key in options) {
|
|
1993
|
+
this[key] = options[key];
|
|
1994
|
+
this.componentOptions.push(key);
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
// Extract props from options that aren't component or view options.
|
|
1998
|
+
const props = {};
|
|
939
1999
|
Object.keys(options).forEach(key => {
|
|
940
|
-
if (
|
|
2000
|
+
if (!this.viewOptions.includes(key) && !this.componentOptions.includes(key)) {
|
|
2001
|
+
props[key] = options[key];
|
|
2002
|
+
}
|
|
941
2003
|
});
|
|
2004
|
+
// Store props as Model for reactive updates.
|
|
2005
|
+
this.props = new Model(props);
|
|
942
2006
|
// Store options by default.
|
|
943
2007
|
this.options = options;
|
|
944
2008
|
// Bind `partial` method to `this`.
|
|
945
2009
|
this.partial = this.partial.bind(this);
|
|
2010
|
+
// Bind `onChange` method to `this`.
|
|
2011
|
+
this.onChange = this.onChange.bind(this);
|
|
946
2012
|
// Call lifecycle method.
|
|
947
2013
|
this.onCreate.apply(this, arguments);
|
|
948
2014
|
}
|
|
949
2015
|
|
|
950
2016
|
/**
|
|
951
|
-
*
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
* @param {Rasti.Model} model A model or emitter object to listen to changes.
|
|
955
|
-
* @return {Rasti.Component} The component instance.
|
|
2017
|
+
* Get events object for automatic event delegation, based on data attributes.
|
|
2018
|
+
* @return {object} The events object.
|
|
2019
|
+
* @private
|
|
956
2020
|
*/
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
2021
|
+
events() {
|
|
2022
|
+
const events = {};
|
|
2023
|
+
// Create events object.
|
|
2024
|
+
this.eventsManager.types.forEach(type => {
|
|
2025
|
+
const dataAttribute = Component.ATTRIBUTE_EVENT(type);
|
|
2026
|
+
// Create a listener function that gets the listener index from the data attribute and calls the listener.
|
|
2027
|
+
const listener = function(event, component, matched) {
|
|
2028
|
+
// Get the listener index from the data attribute.
|
|
2029
|
+
const index = matched.getAttribute(dataAttribute);
|
|
2030
|
+
// Root element listener may not have a data attribute.
|
|
2031
|
+
if (index) {
|
|
2032
|
+
let currentListener = this.eventsManager.listeners[parseInt(index, 10)];
|
|
2033
|
+
if (typeof currentListener === 'string') currentListener = this[currentListener];
|
|
2034
|
+
validateListener(currentListener);
|
|
2035
|
+
// Call the listener.
|
|
2036
|
+
currentListener.call(this, event, component, matched);
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
// Add an event listener to the events object for each event type, using the data attribute
|
|
2040
|
+
// as both a CSS selector and to store the listener's index.
|
|
2041
|
+
events[`${type} [${dataAttribute}]`] = listener;
|
|
2042
|
+
// Add an event listener to the events object for each event type that matches the root element.
|
|
2043
|
+
events[type] = listener;
|
|
2044
|
+
});
|
|
971
2045
|
|
|
2046
|
+
return events;
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
/**
|
|
2050
|
+
* Subscribes to a `change` event on a model or emitter object and invokes the `onChange` lifecycle method.
|
|
2051
|
+
* The subscription is automatically cleaned up when the component is destroyed.
|
|
2052
|
+
* By default, the component subscribes to changes on `this.model`, `this.state`, and `this.props`.
|
|
2053
|
+
*
|
|
2054
|
+
* @param {Object} model - The model or emitter object to listen to.
|
|
2055
|
+
* @param {string} [type='change'] - The event type to listen for.
|
|
2056
|
+
* @param {Function} [listener=this.onChange] - The callback to invoke when the event is emitted.
|
|
2057
|
+
* @returns {Component} The current component instance for chaining.
|
|
2058
|
+
*/
|
|
2059
|
+
subscribe(model, type = 'change', listener = this.onChange) {
|
|
2060
|
+
// Check if model has `on` method.
|
|
2061
|
+
if (model.on) this.listenTo(model, type, listener);
|
|
972
2062
|
return this;
|
|
973
2063
|
}
|
|
974
2064
|
|
|
975
|
-
|
|
976
|
-
* Tell if
|
|
2065
|
+
/**
|
|
2066
|
+
* Tell if `Component` is a container.
|
|
977
2067
|
* In which case, it will not have an element by itself.
|
|
978
2068
|
* It will render a single expression which is expected to return a single component as child.
|
|
979
2069
|
* `this.el` will be a reference to that child component's element.
|
|
980
2070
|
* @return {boolean}
|
|
2071
|
+
* @private
|
|
981
2072
|
*/
|
|
982
2073
|
isContainer() {
|
|
983
|
-
return
|
|
2074
|
+
return this.template.elements.length === 0 && this.template.interpolations.length === 1;
|
|
984
2075
|
}
|
|
985
2076
|
|
|
986
|
-
|
|
987
|
-
* Override. We don't want to ensure an element on instantiation.
|
|
2077
|
+
/**
|
|
2078
|
+
* Override super method. We don't want to ensure an element on instantiation.
|
|
988
2079
|
* We will provide it later.
|
|
2080
|
+
* @private
|
|
989
2081
|
*/
|
|
990
2082
|
ensureElement() {
|
|
2083
|
+
// Store data event listeners.
|
|
2084
|
+
this.eventsManager = new EventsManager();
|
|
2085
|
+
// Store position tracking for recycling.
|
|
2086
|
+
this.pathManager = new PathManager();
|
|
2087
|
+
// Call template function.
|
|
2088
|
+
this.template = getResult(this.template, this);
|
|
991
2089
|
// If el is provided, delegate events.
|
|
992
2090
|
if (this.el) {
|
|
993
2091
|
// If "this.el" is a function, call it to get the element.
|
|
994
2092
|
this.el = getResult(this.el, this);
|
|
995
|
-
|
|
2093
|
+
// Render the component as a string to generate children components.
|
|
2094
|
+
this.toString();
|
|
2095
|
+
// Hydrate the component.
|
|
2096
|
+
this.hydrate(this.el.parentNode);
|
|
996
2097
|
}
|
|
997
2098
|
}
|
|
998
2099
|
|
|
999
|
-
|
|
1000
|
-
* Find view's element on parent node, using its data attribute.
|
|
1001
|
-
* @param parent {node} The parent node.
|
|
1002
|
-
* @return {node} The component's element.
|
|
1003
|
-
*/
|
|
1004
|
-
findElement(parent) {
|
|
1005
|
-
return (parent || document).querySelector(`[${Component.DATA_ATTRIBUTE_UID}="${this.uid}"]`);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/*
|
|
1009
|
-
* Eval attributes expressions.
|
|
1010
|
-
* @return {object} Object containing add, remove and html properties.
|
|
1011
|
-
*/
|
|
1012
|
-
getAttributes() {
|
|
1013
|
-
const add = {};
|
|
1014
|
-
const remove = {};
|
|
1015
|
-
const html = [];
|
|
1016
|
-
|
|
1017
|
-
const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
|
|
1018
|
-
|
|
1019
|
-
if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
|
|
1020
|
-
// Store previous attributes.
|
|
1021
|
-
const previousAttributes = this.previousAttributes || {};
|
|
1022
|
-
this.previousAttributes = attributes;
|
|
1023
|
-
|
|
1024
|
-
Object.keys(attributes).forEach(key => {
|
|
1025
|
-
let value = attributes[key];
|
|
1026
|
-
// Transform bool attribute values
|
|
1027
|
-
if (value === false) {
|
|
1028
|
-
remove[key] = true;
|
|
1029
|
-
} else if (value === true) {
|
|
1030
|
-
add[key] = '';
|
|
1031
|
-
html.push(key);
|
|
1032
|
-
} else {
|
|
1033
|
-
if (value === null || typeof value === 'undefined') value = '';
|
|
1034
|
-
|
|
1035
|
-
add[key] = value;
|
|
1036
|
-
html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
1037
|
-
}
|
|
1038
|
-
});
|
|
1039
|
-
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
1040
|
-
Object.keys(previousAttributes).forEach(key => {
|
|
1041
|
-
if (!(key in attributes)) {
|
|
1042
|
-
remove[key] = true;
|
|
1043
|
-
}
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
return { add, remove, html : html.join(' ') };
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
/*
|
|
2100
|
+
/**
|
|
1050
2101
|
* Used internally on the render process.
|
|
1051
|
-
* Attach the
|
|
2102
|
+
* Attach the `Component` to the dom element providing `this.el`, delegate events,
|
|
2103
|
+
* subscribe to model changes and call `onHydrate` lifecycle method.
|
|
1052
2104
|
* @param parent {node} The parent node.
|
|
1053
|
-
* @return {
|
|
2105
|
+
* @return {Component} The component instance.
|
|
2106
|
+
* @private
|
|
1054
2107
|
*/
|
|
1055
2108
|
hydrate(parent) {
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
if (this.state) this.subscribe(this.state);
|
|
2109
|
+
['model', 'state', 'props'].forEach(key => {
|
|
2110
|
+
if (this[key]) this.subscribe(this[key]);
|
|
2111
|
+
});
|
|
1060
2112
|
|
|
1061
|
-
if (
|
|
1062
|
-
|
|
1063
|
-
this.
|
|
1064
|
-
|
|
1065
|
-
} else {
|
|
2113
|
+
if (this.isContainer()) {
|
|
2114
|
+
// Get references for interpolation marker comments
|
|
2115
|
+
this.template.interpolations[0].hydrate(parent);
|
|
2116
|
+
// Call hydrate on children.
|
|
1066
2117
|
this.children[0].hydrate(parent);
|
|
2118
|
+
// Set the first element as the component's element.
|
|
1067
2119
|
this.el = this.children[0].el;
|
|
2120
|
+
} else {
|
|
2121
|
+
// Search for every element in template using getSelector
|
|
2122
|
+
this.template.elements.forEach((element, index) => {
|
|
2123
|
+
if (index === 0) {
|
|
2124
|
+
element.hydrate(parent);
|
|
2125
|
+
if (this.el) element.ref = this.el;
|
|
2126
|
+
else this.el = element.ref;
|
|
2127
|
+
}
|
|
2128
|
+
else {
|
|
2129
|
+
element.hydrate(this.el);
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
// Delegate events.
|
|
2133
|
+
this.delegateEvents();
|
|
2134
|
+
// Get references for interpolation marker comments
|
|
2135
|
+
this.template.interpolations.forEach(interpolation => interpolation.hydrate(this.el));
|
|
2136
|
+
this.children.forEach(child => child.hydrate(this.el));
|
|
1068
2137
|
}
|
|
1069
|
-
// Call `
|
|
1070
|
-
this.
|
|
2138
|
+
// Call `onHydrate` lifecycle method.
|
|
2139
|
+
this.onHydrate.call(this);
|
|
1071
2140
|
// Return `this` for chaining.
|
|
1072
2141
|
return this;
|
|
1073
2142
|
}
|
|
1074
2143
|
|
|
1075
|
-
|
|
1076
|
-
*
|
|
1077
|
-
*
|
|
1078
|
-
* @
|
|
1079
|
-
* @
|
|
2144
|
+
/**
|
|
2145
|
+
* Get a `comment` marker with same data attribute as this component.
|
|
2146
|
+
* Used to replace the component when it is recycled.
|
|
2147
|
+
* @return {string} The recycle placeholder.
|
|
2148
|
+
* @private
|
|
1080
2149
|
*/
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
if (this.isContainer()) return this.children[0].recycle(parent);
|
|
1084
|
-
// Find placeholder element to be replaced. It has same data attribute as this component.
|
|
1085
|
-
const toBeReplaced = this.findElement(parent);
|
|
1086
|
-
// Replace it with this.el.
|
|
1087
|
-
toBeReplaced.replaceWith(this.el);
|
|
1088
|
-
// Call `onRender` lifecycle method.
|
|
1089
|
-
this.onRender.call(this, Component.RENDER_TYPE_RECYCLE);
|
|
1090
|
-
// Return `this` for chaining.
|
|
1091
|
-
return this;
|
|
2150
|
+
getRecycledMarker() {
|
|
2151
|
+
return `<!--${Component.MARKER_RECYCLED(this.uid)}-->`;
|
|
1092
2152
|
}
|
|
1093
2153
|
|
|
1094
|
-
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
2154
|
+
/**
|
|
2155
|
+
* Get the component nodes to be inserted into the DOM.
|
|
2156
|
+
* Used internally during the render process, you usually don't need to call it if you use
|
|
2157
|
+
* `mount()`.
|
|
2158
|
+
* For components that render HTML elements you can safely rely on `this.el`.
|
|
2159
|
+
* For container components that render another component you need the wrapper nodes to insert
|
|
2160
|
+
* them into the DOM; use `getNodes()` for that.
|
|
2161
|
+
* @return {Node[]} The component nodes.
|
|
1097
2162
|
*/
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
2163
|
+
getNodes() {
|
|
2164
|
+
return this.isContainer() ?
|
|
2165
|
+
[this.template.interpolations[0].ref[0], ...this.children[0].getNodes(), this.template.interpolations[0].ref[1]] :
|
|
2166
|
+
[this.el];
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Used internally on the render process.
|
|
2171
|
+
* Reuse a `Component` by replacing the placeholder comment with the real nodes.
|
|
2172
|
+
* Call `onRecycle` lifecycle method.
|
|
2173
|
+
* @param parent {node} The parent node.
|
|
2174
|
+
* @return {Component} The component instance.
|
|
2175
|
+
* @private
|
|
2176
|
+
*/
|
|
2177
|
+
recycle(parent) {
|
|
2178
|
+
// Locate the placeholder comment and replace it with the real nodes
|
|
2179
|
+
const toBeReplaced = findComment(parent, Component.MARKER_RECYCLED(this.uid), isComponent);
|
|
2180
|
+
// Replace it with this.el.
|
|
2181
|
+
toBeReplaced.replaceWith(...this.getNodes());
|
|
2182
|
+
// Call `onRecycle` lifecycle method.
|
|
2183
|
+
this.onRecycle.call(this);
|
|
1102
2184
|
// Return `this` for chaining.
|
|
1103
2185
|
return this;
|
|
1104
2186
|
}
|
|
@@ -1124,13 +2206,22 @@
|
|
|
1124
2206
|
}
|
|
1125
2207
|
|
|
1126
2208
|
/**
|
|
1127
|
-
* Lifecycle method. Called when the
|
|
1128
|
-
* @param type {string} The render type. Can be `render`, `hydrate` or `recycle`.
|
|
2209
|
+
* Lifecycle method. Called when the component is rendered for the first time and hydrated.
|
|
1129
2210
|
*/
|
|
1130
|
-
|
|
2211
|
+
onHydrate() {}
|
|
1131
2212
|
|
|
1132
2213
|
/**
|
|
1133
|
-
* Lifecycle method. Called when the
|
|
2214
|
+
* Lifecycle method. Called when the component is recycled (reused with the same key) and added to the DOM again.
|
|
2215
|
+
*/
|
|
2216
|
+
onRecycle() {}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Lifecycle method. Called when the component is updated or re-rendered.
|
|
2220
|
+
*/
|
|
2221
|
+
onUpdate() {}
|
|
2222
|
+
|
|
2223
|
+
/**
|
|
2224
|
+
* Lifecycle method. Called when the component is destroyed.
|
|
1134
2225
|
* @param {object} options Options object or any arguments passed to `destroy` method.
|
|
1135
2226
|
*/
|
|
1136
2227
|
onDestroy() {}
|
|
@@ -1138,18 +2229,18 @@
|
|
|
1138
2229
|
/**
|
|
1139
2230
|
* Tagged template helper method.
|
|
1140
2231
|
* Used to create a partial template.
|
|
1141
|
-
* It will return a
|
|
2232
|
+
* It will return a Partial object that preserves structure for position-based recycling.
|
|
1142
2233
|
* Components will be added as children by the parent component. Template strings literals
|
|
1143
2234
|
* will be marked as safe HTML to be rendered.
|
|
1144
2235
|
* This method is bound to the component instance by default.
|
|
1145
2236
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
1146
2237
|
* @param {...any} expressions - Template expressions.
|
|
1147
|
-
* @return {
|
|
2238
|
+
* @return {Partial} Partial object containing strings and expressions.
|
|
1148
2239
|
* @example
|
|
1149
2240
|
* import { Component } from 'rasti';
|
|
1150
2241
|
* // Create a Title component.
|
|
1151
2242
|
* const Title = Component.create`
|
|
1152
|
-
* <h1>${
|
|
2243
|
+
* <h1>${({ props }) => props.children}</h1>
|
|
1153
2244
|
* `;
|
|
1154
2245
|
* // Create Main component.
|
|
1155
2246
|
* const Main = Component.create`
|
|
@@ -1169,156 +2260,204 @@
|
|
|
1169
2260
|
* });
|
|
1170
2261
|
*/
|
|
1171
2262
|
partial(strings, ...expressions) {
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
expandComponents(
|
|
1175
|
-
|
|
1176
|
-
|
|
2263
|
+
const items = splitPlaceholders(
|
|
2264
|
+
parsePartialElements(
|
|
2265
|
+
expandComponents(
|
|
2266
|
+
addPlaceholders(strings, expressions),
|
|
2267
|
+
expressions
|
|
2268
|
+
),
|
|
2269
|
+
expressions
|
|
2270
|
+
),
|
|
2271
|
+
expressions
|
|
2272
|
+
).map(item => getExpressionResult(item, this));
|
|
2273
|
+
|
|
2274
|
+
return new Partial(items);
|
|
1177
2275
|
}
|
|
1178
2276
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
2277
|
+
/**
|
|
2278
|
+
* Render a template part.
|
|
2279
|
+
* @param {any} part - The template part.
|
|
2280
|
+
* @param {function} addChild - The addChild function.
|
|
2281
|
+
* @return {string} The rendered template part.
|
|
2282
|
+
* @private
|
|
2283
|
+
*/
|
|
2284
|
+
renderTemplatePart(part, addChild) {
|
|
2285
|
+
const result = getExpressionResult(part, this);
|
|
2286
|
+
|
|
2287
|
+
const parse = item => {
|
|
2288
|
+
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
2289
|
+
if (item instanceof SafeHTML) return item;
|
|
2290
|
+
if (item instanceof Component) return addChild(item);
|
|
2291
|
+
|
|
2292
|
+
if (item instanceof Partial) {
|
|
2293
|
+
this.pathManager.push();
|
|
2294
|
+
const out = item.items.map(subItem => { this.pathManager.increment(); return parse(subItem); }).join('');
|
|
2295
|
+
this.pathManager.pop();
|
|
2296
|
+
return out;
|
|
2297
|
+
}
|
|
2298
|
+
// Handle arrays (user loops) - disable tracking.
|
|
2299
|
+
if (Array.isArray(item)) {
|
|
2300
|
+
this.pathManager.pause();
|
|
2301
|
+
const out = deepFlat(item).map(parse).join('');
|
|
2302
|
+
this.pathManager.resume();
|
|
2303
|
+
return out;
|
|
2304
|
+
}
|
|
2305
|
+
// InterpolationWrapper: add markers and process maintaining tracking.
|
|
2306
|
+
if (item instanceof InterpolationWrapper) {
|
|
2307
|
+
this.pathManager.increment();
|
|
2308
|
+
const startMarker = `<!--${Component.MARKER_START(item.interpolationUid)}-->`;
|
|
2309
|
+
const endMarker = `<!--${Component.MARKER_END(item.interpolationUid)}-->`;
|
|
2310
|
+
return `${startMarker}${parse(item.result)}${endMarker}`;
|
|
2311
|
+
}
|
|
1184
2312
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
2313
|
+
return Component.sanitize(item);
|
|
2314
|
+
}
|
|
2315
|
+
// Return empty string if item is undefined, null, false, or true.
|
|
2316
|
+
return '';
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
return `${parse(result)}`;
|
|
1188
2320
|
}
|
|
1189
2321
|
|
|
1190
|
-
|
|
1191
|
-
*
|
|
2322
|
+
/**
|
|
2323
|
+
* Render the component as a string.
|
|
2324
|
+
* Used internally on the render process.
|
|
2325
|
+
* Use it for server-side rendering or static site generation.
|
|
2326
|
+
* @return {string} The rendered component.
|
|
1192
2327
|
*/
|
|
1193
2328
|
toString() {
|
|
1194
2329
|
// Normally there won't be any children, but if there are, destroy them.
|
|
1195
2330
|
this.destroyChildren();
|
|
1196
|
-
//
|
|
1197
|
-
|
|
1198
|
-
//
|
|
1199
|
-
|
|
1200
|
-
//
|
|
1201
|
-
const
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
2331
|
+
// Normally there won't be any data event listeners, but if there are, clear them.
|
|
2332
|
+
this.eventsManager.reset();
|
|
2333
|
+
// Reset position tracking.
|
|
2334
|
+
this.pathManager.reset();
|
|
2335
|
+
// Bind addChild method.
|
|
2336
|
+
const addChild = component => {
|
|
2337
|
+
this.pathManager.track(component);
|
|
2338
|
+
return this.addChild(component);
|
|
2339
|
+
};
|
|
2340
|
+
// Render the template parts.
|
|
2341
|
+
return this.template.parts
|
|
2342
|
+
.map(part => this.renderTemplatePart(part, addChild))
|
|
2343
|
+
.join('');
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
/**
|
|
2347
|
+
* Render the `Component`.
|
|
2348
|
+
* - If `this.el` is not present, the `Component` will be rendered as a string inside a `DocumentFragment` and hydrated, making `this.el` available. The `onHydrate` lifecycle method will be called.
|
|
2349
|
+
* - If `this.el` is present, the method will update the attributes and inner HTML of the element, or recreate its child component in the case of a container. The `onUpdate` lifecycle method will be called.
|
|
2350
|
+
* - When rendering child components, recycling happens in two ways:
|
|
2351
|
+
* - Components with a `key` are recycled if a previous child with the same key exists.
|
|
2352
|
+
* - Unkeyed components are recycled if they have the same type and position in the template or partial.
|
|
2353
|
+
* A recycled `Component` will call the `onRecycle` lifecycle method.
|
|
2354
|
+
* - If the active element is inside the component, it will retain focus after the render.
|
|
2355
|
+
* @return {Component} The component instance.
|
|
1212
2356
|
*/
|
|
1213
2357
|
render() {
|
|
1214
2358
|
// Prevent a last re render if view is already destroyed.
|
|
1215
2359
|
if (this.destroyed) return this;
|
|
1216
|
-
|
|
1217
|
-
if (!this.
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
fragment.innerHTML = this;
|
|
1222
|
-
this.hydrate(fragment.content);
|
|
1223
|
-
return this;
|
|
1224
|
-
}
|
|
1225
|
-
// Set `this.el` attributes.
|
|
1226
|
-
const attributes = this.getAttributes();
|
|
1227
|
-
// Remove attributes.
|
|
1228
|
-
Object.keys(attributes.remove).forEach(key => {
|
|
1229
|
-
this.el.removeAttribute(key);
|
|
1230
|
-
});
|
|
1231
|
-
// Add attributes.
|
|
1232
|
-
Object.keys(attributes.add).forEach(key => {
|
|
1233
|
-
this.el.setAttribute(key, attributes.add[key]);
|
|
1234
|
-
});
|
|
2360
|
+
// If `this.el` is not present, render the view as a string and hydrate it.
|
|
2361
|
+
if (!this.el) {
|
|
2362
|
+
const fragment = parseHTML(this);
|
|
2363
|
+
this.hydrate(fragment);
|
|
2364
|
+
return this;
|
|
1235
2365
|
}
|
|
1236
|
-
//
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
2366
|
+
// Clear event listeners.
|
|
2367
|
+
this.eventsManager.reset();
|
|
2368
|
+
// Reset position tracking.
|
|
2369
|
+
this.pathManager.reset();
|
|
2370
|
+
// Store active element.
|
|
2371
|
+
const activeElement = this.isContainer() ? null : document.activeElement;
|
|
2372
|
+
// Update elements.
|
|
2373
|
+
this.template.elements.forEach(element => element.update());
|
|
2374
|
+
// Store previous children.
|
|
2375
|
+
const previousChildren = this.children;
|
|
2376
|
+
// Clear current children.
|
|
2377
|
+
this.children = [];
|
|
2378
|
+
// Update interpolations.
|
|
2379
|
+
this.template.interpolations.forEach(interpolation => {
|
|
1241
2380
|
const nextChildren = [];
|
|
1242
2381
|
const recycledChildren = [];
|
|
1243
2382
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
const inner = this.template.call(this, component => {
|
|
2383
|
+
this.pathManager.increment();
|
|
2384
|
+
|
|
2385
|
+
const addChild = component => {
|
|
1248
2386
|
let out = component;
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
2387
|
+
let found = null;
|
|
2388
|
+
// Check if child already exists by key.
|
|
2389
|
+
if (component.key) {
|
|
2390
|
+
found = previousChildren.find(prev => prev.key === component.key);
|
|
2391
|
+
} else {
|
|
2392
|
+
// Find by position and type.
|
|
2393
|
+
found = this.pathManager.findRecyclable(component.constructor);
|
|
2394
|
+
}
|
|
1253
2395
|
|
|
1254
2396
|
if (found) {
|
|
1255
2397
|
// If child already exists, replace it html by its root element.
|
|
1256
|
-
out = found.
|
|
2398
|
+
out = found.getRecycledMarker();
|
|
1257
2399
|
// Add child to recycled children.
|
|
1258
|
-
recycledChildren.push(found);
|
|
1259
|
-
//
|
|
1260
|
-
|
|
2400
|
+
recycledChildren.push([found, component]);
|
|
2401
|
+
// Track the component.
|
|
2402
|
+
if (!found.key) this.pathManager.track(found);
|
|
1261
2403
|
} else {
|
|
1262
|
-
//
|
|
2404
|
+
// Add new component.
|
|
1263
2405
|
nextChildren.push(component);
|
|
2406
|
+
// Track the component.
|
|
2407
|
+
this.pathManager.track(component);
|
|
1264
2408
|
}
|
|
1265
|
-
//
|
|
2409
|
+
// Return the component or placeholder.
|
|
1266
2410
|
return out;
|
|
1267
|
-
}
|
|
2411
|
+
};
|
|
1268
2412
|
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
} else if (recycledChildren[0]) {
|
|
1282
|
-
this.addChild(recycledChildren[0]);
|
|
1283
|
-
} else {
|
|
1284
|
-
throw new Error('Container component must have a child component');
|
|
1285
|
-
}
|
|
1286
|
-
} else {
|
|
1287
|
-
this.el.innerHTML = inner;
|
|
1288
|
-
// Add new children. Hydrate them.
|
|
1289
|
-
nextChildren.forEach(nextChild => {
|
|
1290
|
-
this.addChild(nextChild).hydrate(this.el);
|
|
1291
|
-
});
|
|
1292
|
-
// Replace children root elements with recycled components.
|
|
1293
|
-
recycledChildren.forEach(recycledChild => {
|
|
1294
|
-
this.addChild(recycledChild).recycle(this.el);
|
|
1295
|
-
});
|
|
1296
|
-
}
|
|
1297
|
-
// Destroy unused children.
|
|
1298
|
-
previousChildren.forEach(previousChild => {
|
|
1299
|
-
const found = recycledChildren.indexOf(previousChild) > -1;
|
|
1300
|
-
if (!found) previousChild.destroy();
|
|
2413
|
+
const fragment = parseHTML(this.renderTemplatePart(interpolation.expression, addChild));
|
|
2414
|
+
// Replace children root elements with recycled components.
|
|
2415
|
+
recycledChildren.forEach(([recycled, discarded]) => {
|
|
2416
|
+
this.addChild(recycled).recycle(fragment);
|
|
2417
|
+
// Update props.
|
|
2418
|
+
recycled.props.set(discarded.props.toJSON());
|
|
2419
|
+
// Destroy discarded component.
|
|
2420
|
+
discarded.destroy();
|
|
2421
|
+
});
|
|
2422
|
+
// Add new children. Hydrate them.
|
|
2423
|
+
nextChildren.forEach(child => {
|
|
2424
|
+
this.addChild(child).hydrate(fragment);
|
|
1301
2425
|
});
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2426
|
+
|
|
2427
|
+
interpolation.update(fragment);
|
|
2428
|
+
});
|
|
2429
|
+
// Destroy unused children.
|
|
2430
|
+
previousChildren.forEach(prev => {
|
|
2431
|
+
if (this.children.indexOf(prev) < 0) prev.destroy();
|
|
2432
|
+
});
|
|
2433
|
+
// If container, set el to the child element.
|
|
2434
|
+
if (this.isContainer()) {
|
|
2435
|
+
this.el = this.children[0].el;
|
|
2436
|
+
} else {
|
|
2437
|
+
// If there are pending event types, delegate events again.
|
|
2438
|
+
if (this.eventsManager.hasPendingTypes()) {
|
|
2439
|
+
this.delegateEvents();
|
|
1305
2440
|
}
|
|
1306
2441
|
}
|
|
1307
|
-
//
|
|
1308
|
-
this.
|
|
2442
|
+
// Restore focus.
|
|
2443
|
+
if (activeElement && this.el.contains(activeElement)) {
|
|
2444
|
+
activeElement.focus();
|
|
2445
|
+
}
|
|
2446
|
+
// Call onUpdate lifecycle method.
|
|
2447
|
+
this.onUpdate.call(this);
|
|
1309
2448
|
// Return this for chaining.
|
|
1310
2449
|
return this;
|
|
1311
2450
|
}
|
|
1312
2451
|
|
|
1313
2452
|
/**
|
|
1314
|
-
* Mark a string as safe HTML to be rendered.
|
|
1315
|
-
* Normally you don't need to use this method, as Rasti will automatically mark
|
|
1316
|
-
* as safe HTML when the component is @link
|
|
1317
|
-
* using the @link
|
|
2453
|
+
* Mark a string as safe HTML to be rendered.
|
|
2454
|
+
* Normally you don't need to use this method, as Rasti will automatically mark string literals
|
|
2455
|
+
* as safe HTML when the component is {@link #module_component_create created} and when
|
|
2456
|
+
* using the {@link #module_component__partial Component.partial} method.
|
|
1318
2457
|
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
1319
2458
|
* @static
|
|
1320
2459
|
* @param {string} value
|
|
1321
|
-
* @return {
|
|
2460
|
+
* @return {SafeHTML} A safe HTML object.
|
|
1322
2461
|
*/
|
|
1323
2462
|
static markAsSafeHTML(value) {
|
|
1324
2463
|
return new SafeHTML(value);
|
|
@@ -1327,7 +2466,7 @@
|
|
|
1327
2466
|
/**
|
|
1328
2467
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
1329
2468
|
* @static
|
|
1330
|
-
* @param {object} object Object containing methods to be added to the new `Component` subclass. Also can be a function that receives the parent prototype and returns an object.
|
|
2469
|
+
* @param {object|Function} object Object containing methods to be added to the new `Component` subclass. Also can be a function that receives the parent prototype and returns an object.
|
|
1331
2470
|
*/
|
|
1332
2471
|
static extend(object) {
|
|
1333
2472
|
const Current = this;
|
|
@@ -1348,27 +2487,28 @@
|
|
|
1348
2487
|
* appends its element into the DOM (if `el` is provided).
|
|
1349
2488
|
* And returns the view instance.
|
|
1350
2489
|
* @static
|
|
1351
|
-
* @param {object} options The view options.
|
|
1352
|
-
* @param {node} el Dom element to append the view element.
|
|
1353
|
-
* @param {boolean} hydrate If true, the view will hydrate existing DOM.
|
|
1354
|
-
* @return {
|
|
2490
|
+
* @param {object} [options] The view options.
|
|
2491
|
+
* @param {node} [el] Dom element to append the view element.
|
|
2492
|
+
* @param {boolean} [hydrate] If true, the view will hydrate existing DOM.
|
|
2493
|
+
* @return {Component} The component instance.
|
|
1355
2494
|
*/
|
|
1356
2495
|
static mount(options, el, hydrate) {
|
|
1357
|
-
// Instantiate
|
|
1358
|
-
const
|
|
2496
|
+
// Instantiate component.
|
|
2497
|
+
const component = new this(options);
|
|
1359
2498
|
// If `el` is passed, mount component.
|
|
1360
2499
|
if (el) {
|
|
1361
2500
|
if (hydrate) {
|
|
2501
|
+
// Generate subcomponents.
|
|
2502
|
+
component.toString();
|
|
1362
2503
|
// Hydrate existing DOM.
|
|
1363
|
-
|
|
1364
|
-
view.hydrate(el);
|
|
2504
|
+
component.hydrate(el);
|
|
1365
2505
|
} else {
|
|
1366
2506
|
// Append element to the DOM.
|
|
1367
|
-
el.
|
|
2507
|
+
el.append(...component.render().getNodes());
|
|
1368
2508
|
}
|
|
1369
2509
|
}
|
|
1370
|
-
// Return
|
|
1371
|
-
return
|
|
2510
|
+
// Return component instance.
|
|
2511
|
+
return component;
|
|
1372
2512
|
}
|
|
1373
2513
|
|
|
1374
2514
|
/**
|
|
@@ -1381,16 +2521,45 @@
|
|
|
1381
2521
|
* - Template interpolations that are functions will be evaluated during the render process, receiving the view instance as an argument and being bound to it. If the function returns `null`, `undefined`, `false`, or an empty string, the interpolation won't render any content.
|
|
1382
2522
|
* ```javascript
|
|
1383
2523
|
* const Button = Component.create`
|
|
1384
|
-
* <button class="${({
|
|
1385
|
-
* ${({
|
|
2524
|
+
* <button class="${({ props }) => props.className}">
|
|
2525
|
+
* ${({ props }) => props.children}
|
|
2526
|
+
* </button>
|
|
2527
|
+
* `;
|
|
2528
|
+
* ```
|
|
2529
|
+
* - Attach DOM event handlers per element using camel-cased attributes.
|
|
2530
|
+
* Event handlers are automatically bound to the component instance (`this`).
|
|
2531
|
+
* Internally, Rasti uses event delegation to the component's root element for performance.
|
|
2532
|
+
*
|
|
2533
|
+
* **Attribute Quoting:**
|
|
2534
|
+
* - **Quoted attributes** (`onClick="${handler}"`) evaluate the expression first, useful for dynamic values
|
|
2535
|
+
* - **Unquoted attributes** (`onClick=${handler}`) pass the function reference directly
|
|
2536
|
+
*
|
|
2537
|
+
* **Listener Signature:** `(event, component, matched)`
|
|
2538
|
+
* - `event`: The native DOM event object
|
|
2539
|
+
* - `component`: The component instance (same as `this`)
|
|
2540
|
+
* - `matched`: The element that matched the event (useful for delegation)
|
|
2541
|
+
*
|
|
2542
|
+
* ```javascript
|
|
2543
|
+
* const Button = Component.create`
|
|
2544
|
+
* <button
|
|
2545
|
+
* onClick=${function(event, component, matched) {
|
|
2546
|
+
* // this === component
|
|
2547
|
+
* console.log('Button clicked:', matched);
|
|
2548
|
+
* }}
|
|
2549
|
+
* onMouseOver="${({ model }) => () => model.isHovered = true}"
|
|
2550
|
+
* onMouseOut="${({ model }) => () => model.isHovered = false}"
|
|
2551
|
+
* >
|
|
2552
|
+
* Click me
|
|
1386
2553
|
* </button>
|
|
1387
2554
|
* `;
|
|
1388
2555
|
* ```
|
|
1389
|
-
*
|
|
2556
|
+
*
|
|
2557
|
+
* If you need custom delegation (e.g., `{'click .selector': 'handler'}`),
|
|
2558
|
+
* you may override the `events` property as described in {@link #module_view__delegateevents View.delegateEvents}.
|
|
1390
2559
|
* - Boolean attributes should be passed in the format `attribute="${() => true}"`. `false` attributes won't be rendered. `true` attributes will be rendered without a value.
|
|
1391
2560
|
* ```javascript
|
|
1392
2561
|
* const Input = Component.create`
|
|
1393
|
-
* <input type="text" disabled=${({
|
|
2562
|
+
* <input type="text" disabled=${({ props }) => props.disabled} />
|
|
1394
2563
|
* `;
|
|
1395
2564
|
* ```
|
|
1396
2565
|
* - If the interpolated function returns a component instance, it will be added as a child component.
|
|
@@ -1399,21 +2568,21 @@
|
|
|
1399
2568
|
* // Create a button component.
|
|
1400
2569
|
* const Button = Component.create`
|
|
1401
2570
|
* <button class="button">
|
|
1402
|
-
* ${({
|
|
2571
|
+
* ${({ props }) => props.children}
|
|
1403
2572
|
* </button>
|
|
1404
2573
|
* `;
|
|
1405
2574
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1406
2575
|
* const Navigation = Component.create`
|
|
1407
2576
|
* <nav>
|
|
1408
|
-
* ${({
|
|
1409
|
-
* item => Button.mount({
|
|
2577
|
+
* ${({ props }) => props.items.map(
|
|
2578
|
+
* item => Button.mount({ children : item.label })
|
|
1410
2579
|
* )}
|
|
1411
2580
|
* </nav>
|
|
1412
2581
|
* `;
|
|
1413
2582
|
* // Create a header component. Add navigation as a child.
|
|
1414
2583
|
* const Header = Component.create`
|
|
1415
2584
|
* <header>
|
|
1416
|
-
* ${({
|
|
2585
|
+
* ${({ props }) => Navigation.mount({ items : props.items})}
|
|
1417
2586
|
* </header>
|
|
1418
2587
|
* `;
|
|
1419
2588
|
* ```
|
|
@@ -1422,21 +2591,21 @@
|
|
|
1422
2591
|
* // Create a button component.
|
|
1423
2592
|
* const Button = Component.create`
|
|
1424
2593
|
* <button class="button">
|
|
1425
|
-
* ${({
|
|
2594
|
+
* ${({ props }) => props.children}
|
|
1426
2595
|
* </button>
|
|
1427
2596
|
* `;
|
|
1428
2597
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1429
2598
|
* const Navigation = Component.create`
|
|
1430
2599
|
* <nav>
|
|
1431
|
-
* ${
|
|
1432
|
-
* item =>
|
|
2600
|
+
* ${({ props, partial }) => props.items.map(
|
|
2601
|
+
* item => partial`<${Button}>${item.label}</${Button}>`
|
|
1433
2602
|
* )}
|
|
1434
2603
|
* </nav>
|
|
1435
2604
|
* `;
|
|
1436
2605
|
* // Create a header component. Add navigation as a child.
|
|
1437
2606
|
* const Header = Component.create`
|
|
1438
2607
|
* <header>
|
|
1439
|
-
* <${Navigation} items="${({
|
|
2608
|
+
* <${Navigation} items="${({ props }) => props.items}" />
|
|
1440
2609
|
* </header>
|
|
1441
2610
|
* `;
|
|
1442
2611
|
* ```
|
|
@@ -1444,117 +2613,123 @@
|
|
|
1444
2613
|
* ```javascript
|
|
1445
2614
|
* // Create a button component.
|
|
1446
2615
|
* const Button = Component.create`
|
|
1447
|
-
* <button class="${({
|
|
1448
|
-
* ${
|
|
2616
|
+
* <button class="${({ props }) => props.className}">
|
|
2617
|
+
* ${({ props }) => props.children}
|
|
1449
2618
|
* </button>
|
|
1450
2619
|
* `;
|
|
1451
|
-
* // Create a container
|
|
2620
|
+
* // Create a container that renders a Button component.
|
|
1452
2621
|
* const ButtonOk = Component.create`
|
|
1453
2622
|
* <${Button} className="ok">Ok</${Button}>
|
|
1454
2623
|
* `;
|
|
1455
|
-
* // Create a
|
|
2624
|
+
* // Create a container that renders a Button component, using a function.
|
|
1456
2625
|
* const ButtonCancel = Component.create(() => Button.mount({
|
|
1457
|
-
* className: 'cancel',
|
|
1458
|
-
*
|
|
2626
|
+
* className : 'cancel',
|
|
2627
|
+
* children : 'Cancel'
|
|
1459
2628
|
* }));
|
|
1460
2629
|
* ```
|
|
1461
2630
|
* @static
|
|
1462
|
-
* @param {string|
|
|
2631
|
+
* @param {string|Function} strings - HTML template for the component or a function that mounts a sub component.
|
|
1463
2632
|
* @param {...*} expressions - The expressions to be interpolated within the template.
|
|
1464
|
-
* @return {
|
|
2633
|
+
* @return {Component} The newly created component class.
|
|
1465
2634
|
*/
|
|
1466
2635
|
static create(strings, ...expressions) {
|
|
1467
|
-
const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
|
|
1468
2636
|
// Containers can be created using create as a functions instead of a tagged template.
|
|
1469
2637
|
if (typeof strings === 'function') {
|
|
1470
2638
|
expressions = [strings];
|
|
1471
2639
|
strings = ['', ''];
|
|
1472
2640
|
}
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
2641
|
+
// Create elements, interpolations and parts arrays.
|
|
2642
|
+
const elements = [], interpolations = [];
|
|
2643
|
+
const parts = splitPlaceholders(
|
|
2644
|
+
parseInterpolations(
|
|
2645
|
+
parseElements(
|
|
2646
|
+
expandComponents(
|
|
2647
|
+
addPlaceholders(
|
|
2648
|
+
strings,
|
|
2649
|
+
expressions
|
|
2650
|
+
).trim(),
|
|
2651
|
+
expressions
|
|
2652
|
+
),
|
|
2653
|
+
expressions,
|
|
2654
|
+
elements
|
|
2655
|
+
),
|
|
2656
|
+
expressions,
|
|
2657
|
+
interpolations
|
|
2658
|
+
),
|
|
2659
|
+
expressions
|
|
1480
2660
|
);
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
1498
|
-
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
1499
|
-
|
|
1500
|
-
Object.keys(typeListeners).forEach(selector => {
|
|
1501
|
-
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
1502
|
-
});
|
|
1503
|
-
|
|
1504
|
-
return out;
|
|
1505
|
-
}, {});
|
|
1506
|
-
};
|
|
1507
|
-
// If there is a closing tag, get template.
|
|
1508
|
-
if (close) {
|
|
1509
|
-
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
1510
|
-
template = function(addChild) {
|
|
1511
|
-
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
1512
|
-
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
1513
|
-
if (item instanceof SafeHTML) return item;
|
|
1514
|
-
if (item instanceof Component) return addChild(item);
|
|
1515
|
-
return Component.sanitize(item);
|
|
1516
|
-
}
|
|
1517
|
-
return '';
|
|
1518
|
-
}).join('');
|
|
2661
|
+
// Create subclass for this component.
|
|
2662
|
+
return this.extend({
|
|
2663
|
+
template() {
|
|
2664
|
+
return {
|
|
2665
|
+
elements : elements.map(element => new Element({
|
|
2666
|
+
getSelector : element.getSelector.bind(this),
|
|
2667
|
+
getAttributes : element.getAttributes.bind(this)
|
|
2668
|
+
})),
|
|
2669
|
+
interpolations : interpolations.map(interpolation => new Interpolation({
|
|
2670
|
+
getStart : interpolation.getStart.bind(this),
|
|
2671
|
+
getEnd : interpolation.getEnd.bind(this),
|
|
2672
|
+
expression : interpolation.expression,
|
|
2673
|
+
isComponent,
|
|
2674
|
+
isElement
|
|
2675
|
+
})),
|
|
2676
|
+
parts,
|
|
1519
2677
|
};
|
|
1520
2678
|
}
|
|
1521
|
-
} else {
|
|
1522
|
-
// It's a container.
|
|
1523
|
-
match = main.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
1524
|
-
|
|
1525
|
-
if (match) {
|
|
1526
|
-
// If there is only one expression and no tag, is a container.
|
|
1527
|
-
template = function(addChild) {
|
|
1528
|
-
// Replace expressions.
|
|
1529
|
-
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
1530
|
-
};
|
|
1531
|
-
} else {
|
|
1532
|
-
throw new SyntaxError('Invalid component');
|
|
1533
|
-
}
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
const Current = this;
|
|
1537
|
-
// Create subclass for this component.
|
|
1538
|
-
return Current.extend({
|
|
1539
|
-
// Set root element tag.
|
|
1540
|
-
tag,
|
|
1541
|
-
// Set attributes.
|
|
1542
|
-
attributes,
|
|
1543
|
-
// Set events.
|
|
1544
|
-
events,
|
|
1545
|
-
// Set template.
|
|
1546
|
-
template
|
|
1547
2679
|
});
|
|
1548
2680
|
}
|
|
1549
2681
|
}
|
|
1550
2682
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
Component.
|
|
1555
|
-
Component.
|
|
2683
|
+
/*
|
|
2684
|
+
* Attributes used to identify elements and events.
|
|
2685
|
+
*/
|
|
2686
|
+
Component.ATTRIBUTE_ELEMENT = 'data-rasti-el';
|
|
2687
|
+
Component.ATTRIBUTE_EVENT = (type) => `data-rasti-on-${type}`;
|
|
2688
|
+
|
|
2689
|
+
/*
|
|
2690
|
+
* Placeholders used to temporarily replace expressions in the template.
|
|
2691
|
+
*/
|
|
2692
|
+
Component.PLACEHOLDER = (idx) => `__RASTI-${idx}__`;
|
|
2693
|
+
|
|
2694
|
+
/*
|
|
2695
|
+
* Markers used to identify interpolation and recycled components.
|
|
2696
|
+
*/
|
|
2697
|
+
Component.MARKER_RECYCLED = (uid) => `rasti-recycled-${uid}`;
|
|
2698
|
+
Component.MARKER_START = (uid) => `rasti-start-${uid}`;
|
|
2699
|
+
Component.MARKER_END = (uid) => `rasti-end-${uid}`;
|
|
2700
|
+
|
|
2701
|
+
/**
|
|
2702
|
+
* Components are a special kind of `View` that is designed to be easily composable,
|
|
2703
|
+
* making it simple to add child views and build complex user interfaces.
|
|
2704
|
+
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
2705
|
+
* guidelines that allow for a more declarative development style.
|
|
2706
|
+
* Components are defined with the {@link #module_component_create Component.create} static method, which takes a tagged template string or a function that returns another component.
|
|
2707
|
+
* @module
|
|
2708
|
+
* @extends View
|
|
2709
|
+
* @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onHydrate, onRecycle, onUpdate, onCreate, onChange. Any additional options not in the component or view options list will be automatically extracted as props and stored as `this.props`.
|
|
2710
|
+
* @property {string} [key] A unique key to identify the component. Components with keys are recycled when the same key is found in the previous render. Unkeyed components are recycled based on type and position.
|
|
2711
|
+
* @property {Rasti.Model} [model] A `Rasti.Model` or any emitter object containing data and business logic. The component will listen to `change` events and call `onChange` lifecycle method.
|
|
2712
|
+
* @property {Rasti.Model} [state] A `Rasti.Model` or any emitter object containing data and business logic, to be used as internal state. The component will listen to `change` events and call `onChange` lifecycle method.
|
|
2713
|
+
* @property {Rasti.Model} [props] Automatically created from any options not merged to the component instance. Contains props passed from parent component as a `Rasti.Model`. The component will listen to `change` events on props and call `onChange` lifecycle method. When a component with a `key` is recycled during parent re-render, new props are automatically updated and any changes trigger a re-render.
|
|
2714
|
+
* @see {@link #module_component_create Component.create}
|
|
2715
|
+
* @example
|
|
2716
|
+
* import { Component, Model } from 'rasti';
|
|
2717
|
+
* // Create Timer component.
|
|
2718
|
+
* const Timer = Component.create`
|
|
2719
|
+
* <div>
|
|
2720
|
+
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
2721
|
+
* </div>
|
|
2722
|
+
* `;
|
|
2723
|
+
* // Create model to store seconds.
|
|
2724
|
+
* const model = new Model({ seconds: 0 });
|
|
2725
|
+
* // Mount timer on body.
|
|
2726
|
+
* Timer.mount({ model }, document.body);
|
|
2727
|
+
* // Increment `model.seconds` every second.
|
|
2728
|
+
* setInterval(() => model.seconds++, 1000);
|
|
2729
|
+
*/
|
|
2730
|
+
var Component$1 = Component.create`<div></div>`;
|
|
1556
2731
|
|
|
1557
|
-
exports.Component = Component;
|
|
2732
|
+
exports.Component = Component$1;
|
|
1558
2733
|
exports.Emitter = Emitter;
|
|
1559
2734
|
exports.Model = Model;
|
|
1560
2735
|
exports.View = View;
|