rasti 3.0.1 → 4.0.0-alpha.1
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 +40 -19
- package/dist/rasti.js +1787 -628
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +748 -473
- package/es/Emitter.js +182 -28
- package/es/Model.js +237 -51
- package/es/View.js +73 -31
- 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 +749 -474
- package/lib/Emitter.cjs +182 -28
- package/lib/Model.cjs +237 -51
- package/lib/View.cjs +73 -31
- 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 +745 -472
- package/src/Emitter.js +182 -28
- package/src/Model.js +236 -51
- package/src/View.js +73 -31
- 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
|
+
*
|
|
94
138
|
* @example
|
|
95
|
-
* //
|
|
139
|
+
* // Remove all listeners from this emitter
|
|
140
|
+
* this.model.off();
|
|
141
|
+
*
|
|
142
|
+
* @example
|
|
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,16 +577,101 @@
|
|
|
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
|
*/
|
|
@@ -340,15 +692,15 @@
|
|
|
340
692
|
* @module
|
|
341
693
|
* @extends Emitter
|
|
342
694
|
* @param {object} options Object containing options. The following keys will be merged into the view instance: `el`, `tag`, `attributes`, `events`, `model`, `template`, `onDestroy`.
|
|
343
|
-
* @property {node|
|
|
344
|
-
* @property {string|
|
|
345
|
-
* @property {object|
|
|
346
|
-
* @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}.
|
|
347
699
|
* @property {object} model A model or any object containing data and business logic.
|
|
348
|
-
* @property {
|
|
700
|
+
* @property {Function} template A function that returns a string with the view's inner HTML. See {@link module_view__render View.render}.
|
|
349
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.
|
|
350
702
|
* @example
|
|
351
|
-
* import { View } from 'rasti';
|
|
703
|
+
* import { View, Model } from 'rasti';
|
|
352
704
|
*
|
|
353
705
|
* class Timer extends View {
|
|
354
706
|
* constructor(options) {
|
|
@@ -373,9 +725,6 @@
|
|
|
373
725
|
super();
|
|
374
726
|
// Call preinitialize.
|
|
375
727
|
this.preinitialize.apply(this, arguments);
|
|
376
|
-
// Generate unique id.
|
|
377
|
-
// Useful to generate element ids.
|
|
378
|
-
this.uid = `rasti-${++View.uid}`;
|
|
379
728
|
// Store delegated event listeners,
|
|
380
729
|
// so they can be unbound later.
|
|
381
730
|
this.delegatedEventListeners = [];
|
|
@@ -384,17 +733,23 @@
|
|
|
384
733
|
this.children = [];
|
|
385
734
|
// Mutable array to store handlers to be called on destroy.
|
|
386
735
|
this.destroyQueue = [];
|
|
736
|
+
this.viewOptions = [];
|
|
387
737
|
// Extend "this" with options.
|
|
388
738
|
viewOptions.forEach(key => {
|
|
389
|
-
if (key in options)
|
|
739
|
+
if (key in options) {
|
|
740
|
+
this[key] = options[key];
|
|
741
|
+
this.viewOptions.push(key);
|
|
742
|
+
}
|
|
390
743
|
});
|
|
744
|
+
// Ensure that the view has a unique id at `this.uid`.
|
|
745
|
+
this.ensureUid();
|
|
391
746
|
// Ensure that the view has a root element at `this.el`.
|
|
392
747
|
this.ensureElement();
|
|
393
748
|
}
|
|
394
749
|
|
|
395
750
|
/**
|
|
396
751
|
* If you define a preinitialize method, it will be invoked when the view is first created, before any instantiation logic is run.
|
|
397
|
-
* @param {object}
|
|
752
|
+
* @param {object} options The view options.
|
|
398
753
|
*/
|
|
399
754
|
preinitialize() {}
|
|
400
755
|
|
|
@@ -429,6 +784,8 @@
|
|
|
429
784
|
this.destroyChildren();
|
|
430
785
|
// Undelegate `this.el` event listeners
|
|
431
786
|
this.undelegateEvents();
|
|
787
|
+
// Stop listening to events.
|
|
788
|
+
this.stopListening();
|
|
432
789
|
// Unbind `this` events.
|
|
433
790
|
this.off();
|
|
434
791
|
// Call destroy queue.
|
|
@@ -436,6 +793,8 @@
|
|
|
436
793
|
this.destroyQueue = [];
|
|
437
794
|
// Call onDestroy lifecycle method
|
|
438
795
|
this.onDestroy.apply(this, arguments);
|
|
796
|
+
// Set destroyed flag.
|
|
797
|
+
this.destroyed = true;
|
|
439
798
|
// Return `this` for chaining.
|
|
440
799
|
return this;
|
|
441
800
|
}
|
|
@@ -467,6 +826,13 @@
|
|
|
467
826
|
this.children = [];
|
|
468
827
|
}
|
|
469
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
|
+
|
|
470
836
|
/**
|
|
471
837
|
* Ensure that the view has a root element at `this.el`.
|
|
472
838
|
* You shouldn't call this method directly. It's called from the constructor.
|
|
@@ -533,26 +899,43 @@
|
|
|
533
899
|
* All attached listeners are bound to the view, ensuring that `this` refers to the view object when the listeners are invoked.
|
|
534
900
|
* When `delegateEvents` is called again, possibly with a different events object, all previous listeners are removed and delegated afresh.
|
|
535
901
|
*
|
|
536
|
-
*
|
|
537
|
-
*
|
|
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
|
+
*
|
|
538
910
|
* @param {object} [events] Object in the format `{'event selector' : 'listener'}`. Used to bind delegated event listeners to the root element.
|
|
539
911
|
* @return {Rasti.View} Returns `this` for chaining.
|
|
540
912
|
* @example
|
|
541
|
-
* // Using
|
|
913
|
+
* // Using prototype (recommended for static events)
|
|
542
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 {
|
|
543
932
|
* events() {
|
|
544
933
|
* return {
|
|
545
|
-
*
|
|
546
|
-
* 'click
|
|
934
|
+
* [`click .${this.model.buttonClass}`]: 'onButtonClick',
|
|
935
|
+
* 'click': 'onRootClick'
|
|
547
936
|
* };
|
|
548
937
|
* }
|
|
549
938
|
* }
|
|
550
|
-
*
|
|
551
|
-
* // Using an object.
|
|
552
|
-
* Modal.prototype.events = {
|
|
553
|
-
* 'click button.ok' : 'onClickOkButton',
|
|
554
|
-
* 'click button.cancel' : function() {}
|
|
555
|
-
* };
|
|
556
939
|
*/
|
|
557
940
|
delegateEvents(events) {
|
|
558
941
|
if (!events) events = getResult(this.events, this);
|
|
@@ -569,13 +952,10 @@
|
|
|
569
952
|
const selector = keyParts.join(' ');
|
|
570
953
|
|
|
571
954
|
let listener = events[key];
|
|
572
|
-
// Listener may be a string representing a method name on the view,
|
|
573
|
-
|
|
574
|
-
listener
|
|
575
|
-
|
|
576
|
-
this[listener] :
|
|
577
|
-
listener
|
|
578
|
-
).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);
|
|
579
959
|
|
|
580
960
|
if (!eventTypes[type]) eventTypes[type] = [];
|
|
581
961
|
|
|
@@ -587,7 +967,20 @@
|
|
|
587
967
|
const typeListener = (event) => {
|
|
588
968
|
// Iterate and run every individual listener if the selector matches.
|
|
589
969
|
eventTypes[type].forEach(({ selector, listener }) => {
|
|
590
|
-
|
|
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
|
+
}
|
|
591
984
|
});
|
|
592
985
|
};
|
|
593
986
|
|
|
@@ -641,7 +1034,7 @@
|
|
|
641
1034
|
* Override this method to provide a custom escape function.
|
|
642
1035
|
* This method is inherited by {@link #module_component Component} and used to escape template interpolations.
|
|
643
1036
|
* @static
|
|
644
|
-
* @param {string}
|
|
1037
|
+
* @param {string} value String to escape.
|
|
645
1038
|
* @return {string} Escaped string.
|
|
646
1039
|
*/
|
|
647
1040
|
static sanitize(value) {
|
|
@@ -668,20 +1061,8 @@
|
|
|
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.
|
|
684
|
-
* @class SafeHTML
|
|
685
1066
|
* @param {string} value The HTML string to be marked as safe.
|
|
686
1067
|
* @property {string} value The HTML string.
|
|
687
1068
|
* @private
|
|
@@ -697,282 +1078,1001 @@
|
|
|
697
1078
|
}
|
|
698
1079
|
|
|
699
1080
|
/**
|
|
700
|
-
*
|
|
701
|
-
*
|
|
702
|
-
* @param {any} expression The expression to be evaluated.
|
|
703
|
-
* @param {any} context The context to call the expression with.
|
|
704
|
-
* @return {any} The result of the evaluated expression.
|
|
1081
|
+
* Wrapper class for partial templates that preserves structure.
|
|
1082
|
+
* @param {Array} items The items in the partial.
|
|
705
1083
|
* @private
|
|
706
1084
|
*/
|
|
707
|
-
|
|
1085
|
+
class Partial {
|
|
1086
|
+
constructor(items) {
|
|
1087
|
+
this.items = items;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
708
1090
|
|
|
709
1091
|
/**
|
|
710
|
-
*
|
|
711
|
-
* @param
|
|
712
|
-
* @param
|
|
713
|
-
* @return {string} String with placeholders.
|
|
1092
|
+
* Wrapper for interpolation results with markers.
|
|
1093
|
+
* @param {number} interpolationUid The interpolation UID for markers.
|
|
1094
|
+
* @param {any} result The interpolation result.
|
|
714
1095
|
* @private
|
|
715
1096
|
*/
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
out.push(Component.PLACEHOLDER_EXPRESSION(i));
|
|
723
|
-
}
|
|
724
|
-
return out;
|
|
725
|
-
}, []).join('');
|
|
1097
|
+
class InterpolationWrapper {
|
|
1098
|
+
constructor(interpolationUid, result) {
|
|
1099
|
+
this.interpolationUid = interpolationUid;
|
|
1100
|
+
this.result = result;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
726
1103
|
|
|
727
1104
|
/**
|
|
728
|
-
*
|
|
729
|
-
* @param main {string} The main template containing placeholders.
|
|
730
|
-
* @param expressions {array} Array of expressions to replace placeholders.
|
|
731
|
-
* @return {array} Array containing strings and expressions.
|
|
1105
|
+
* Manager for delegated events.
|
|
732
1106
|
* @private
|
|
733
1107
|
*/
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
let match;
|
|
740
|
-
// Generate one dimensional array with strings and expressions,
|
|
741
|
-
// so all the components are added as children by the parent component.
|
|
742
|
-
while ((match = regExp.exec(main)) !== null) {
|
|
743
|
-
const before = main.slice(lastIndex, match.index);
|
|
744
|
-
out.push(Component.markAsSafeHTML(before), expressions[match[1]]);
|
|
745
|
-
lastIndex = match.index + match[0].length;
|
|
1108
|
+
class EventsManager {
|
|
1109
|
+
constructor() {
|
|
1110
|
+
this.listeners = [];
|
|
1111
|
+
this.types = new Set();
|
|
1112
|
+
this.previousSize = 0;
|
|
746
1113
|
}
|
|
747
|
-
out.push(Component.markAsSafeHTML(main.slice(lastIndex)));
|
|
748
1114
|
|
|
749
|
-
|
|
750
|
-
|
|
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
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Reset the events manager.
|
|
1129
|
+
*/
|
|
1130
|
+
reset() {
|
|
1131
|
+
this.listeners = [];
|
|
1132
|
+
this.previousSize = this.types.size;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
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
|
+
}
|
|
751
1143
|
|
|
752
1144
|
/**
|
|
753
|
-
*
|
|
754
|
-
* @param attributes {array} Array of attributes as key, value pairs.
|
|
755
|
-
* @param getExpressionResult {function} Function to render expressions.
|
|
756
|
-
* @return {object}
|
|
757
|
-
* @property {object} all All attributes.
|
|
758
|
-
* @property {object} events Event listeners.
|
|
759
|
-
* @property {object} attributes Attributes.
|
|
1145
|
+
* Manager for component position tracking and recycling.
|
|
760
1146
|
* @private
|
|
761
1147
|
*/
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const attribute = getExpressionResult(pair[0]);
|
|
765
|
-
// Attribute without value.
|
|
766
|
-
if (pair.length === 1) {
|
|
767
|
-
if (typeof attribute === 'object') {
|
|
768
|
-
// Expand objects as attributes.
|
|
769
|
-
out.all = Object.assign(out.all, attribute);
|
|
770
|
-
} else if (typeof attribute === 'string') {
|
|
771
|
-
// Treat as boolean.
|
|
772
|
-
out.all[attribute] = true;
|
|
773
|
-
}
|
|
774
|
-
} else {
|
|
775
|
-
// Attribute with value.
|
|
776
|
-
const value = getExpressionResult(pair[1]);
|
|
777
|
-
out.all[attribute] = value;
|
|
778
|
-
}
|
|
1148
|
+
class PathManager {
|
|
1149
|
+
constructor() {}
|
|
779
1150
|
|
|
780
|
-
|
|
781
|
-
|
|
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
|
+
}
|
|
782
1160
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* Push position to stack.
|
|
1163
|
+
*/
|
|
1164
|
+
push() {
|
|
1165
|
+
this.positionStack.push(0);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
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
|
+
);
|
|
792
1215
|
}
|
|
793
|
-
});
|
|
794
1216
|
|
|
795
|
-
|
|
796
|
-
|
|
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
|
+
}
|
|
797
1230
|
|
|
798
1231
|
/**
|
|
799
|
-
*
|
|
800
|
-
*
|
|
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
|
|
801
1713
|
* by a function that mounts the component.
|
|
802
1714
|
* Returns the template with component tags replaced by expressions placeholders
|
|
803
1715
|
* modifies the expressions array adding the mount functions.
|
|
804
1716
|
* @param main {string} The main template.
|
|
1717
|
+
* @param {Array<any>} expressions Array of expressions.
|
|
1718
|
+
* @param {boolean} skipNormalization Skip placeholder normalization (for recursive calls).
|
|
805
1719
|
* @return {string} The template with components tags replaced by expressions
|
|
806
1720
|
* placeholders.
|
|
807
1721
|
* @private
|
|
808
1722
|
*/
|
|
809
|
-
const expandComponents = (main, expressions) => {
|
|
810
|
-
const PH = Component.
|
|
811
|
-
|
|
1723
|
+
const expandComponents = (main, expressions, skipNormalization = false) => {
|
|
1724
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1725
|
+
const componentRefMap = new Map();
|
|
1726
|
+
// Normalize component references to use first placeholder index.
|
|
1727
|
+
// Only on first call, not on recursive calls.
|
|
1728
|
+
if (!skipNormalization) {
|
|
1729
|
+
main = main.replace(
|
|
1730
|
+
new RegExp(PH, 'g'),
|
|
1731
|
+
(match, idx) => {
|
|
1732
|
+
const expression = expressions[idx];
|
|
1733
|
+
if (expression && expression.prototype instanceof Component) {
|
|
1734
|
+
if (componentRefMap.has(expression)) {
|
|
1735
|
+
return componentRefMap.get(expression);
|
|
1736
|
+
}
|
|
1737
|
+
componentRefMap.set(expression, match);
|
|
1738
|
+
}
|
|
1739
|
+
return match;
|
|
1740
|
+
}
|
|
1741
|
+
);
|
|
1742
|
+
}
|
|
1743
|
+
// Match component tags with backreference to ensure correct pairing.
|
|
812
1744
|
return main.replace(
|
|
813
|
-
new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
// No component found.
|
|
817
|
-
if (!(tag.prototype instanceof Component)) return raw;
|
|
1745
|
+
new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</\\1>|<(${PH})([^>]*)/>`,'g'),
|
|
1746
|
+
(match, openTag, openIdx, nonVoidAttrs, inner, selfClosingTag, selfClosingIdx, selfClosingAttrs) => {
|
|
1747
|
+
let tag, attributesStr, innerList;
|
|
818
1748
|
|
|
819
|
-
|
|
1749
|
+
if (openTag) {
|
|
1750
|
+
tag = expressions[openIdx];
|
|
1751
|
+
attributesStr = nonVoidAttrs;
|
|
1752
|
+
} else {
|
|
1753
|
+
tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
|
|
1754
|
+
attributesStr = selfClosingAttrs;
|
|
1755
|
+
}
|
|
1756
|
+
// No component found.
|
|
1757
|
+
if (!(tag.prototype instanceof Component)) return match;
|
|
820
1758
|
// Non void component.
|
|
821
|
-
if (
|
|
822
|
-
//
|
|
823
|
-
if (tag !== close) return raw;
|
|
1759
|
+
if (openTag) {
|
|
1760
|
+
// Process inner content same way as partial().
|
|
824
1761
|
// Recursively expand inner components.
|
|
825
|
-
const
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1762
|
+
const innerTemplate = expandComponents(inner, expressions, true);
|
|
1763
|
+
// Parse partial elements to handle dynamic attributes and events.
|
|
1764
|
+
const parsedInner = parsePartialElements(innerTemplate, expressions);
|
|
1765
|
+
// Split into items.
|
|
1766
|
+
innerList = splitPlaceholders(parsedInner, expressions);
|
|
830
1767
|
}
|
|
1768
|
+
// Parse attributes.
|
|
1769
|
+
const attributes = parseAttributes(attributesStr, expressions);
|
|
831
1770
|
// Create mount function.
|
|
832
1771
|
const mount = function() {
|
|
833
|
-
const options = expandAttributes(attributes, value => getExpressionResult(value, this))
|
|
834
|
-
// Add renderChildren function to options.
|
|
835
|
-
if (
|
|
1772
|
+
const options = expandAttributes(attributes, value => getExpressionResult(value, this));
|
|
1773
|
+
// Add `renderChildren` function to options.
|
|
1774
|
+
if (innerList) {
|
|
1775
|
+
// Evaluate items in parent context and create Partial.
|
|
1776
|
+
options.renderChildren = () => new Partial(innerList.map(item => getExpressionResult(item, this)));
|
|
1777
|
+
}
|
|
836
1778
|
// Mount component.
|
|
837
1779
|
return tag.mount(options);
|
|
838
1780
|
};
|
|
839
1781
|
// Add mount function to expression.
|
|
840
1782
|
expressions.push(mount);
|
|
841
1783
|
// Replace whole string with expression placeholder.
|
|
842
|
-
return Component.
|
|
1784
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
843
1785
|
}
|
|
844
1786
|
);
|
|
845
1787
|
};
|
|
846
1788
|
|
|
847
1789
|
/**
|
|
848
|
-
*
|
|
849
|
-
* @param
|
|
850
|
-
* @
|
|
851
|
-
* @
|
|
852
|
-
* @property {string} inner The inner html.
|
|
853
|
-
* @property {string} close The closing tag.
|
|
854
|
-
* @property {array} attributes Array of attributes as key, value pairs.
|
|
855
|
-
* @property {string} raw The whole match.
|
|
1790
|
+
* Replace elements in template.
|
|
1791
|
+
* @param {string} template Template string.
|
|
1792
|
+
* @param {Function} replacer Replacer function.
|
|
1793
|
+
* @return {string} Template string with replaced elements.
|
|
856
1794
|
* @private
|
|
857
1795
|
*/
|
|
858
|
-
const
|
|
859
|
-
const PH = Component.
|
|
1796
|
+
const replaceElements = (template, replacer) => {
|
|
1797
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1798
|
+
return template.replace(
|
|
1799
|
+
new RegExp(`<(${PH}|[a-z]+[1-6]?)(?:\\s*)((?:"[^"]*"|'[^']*'|[^>])*)(/?>)`, 'gi'),
|
|
1800
|
+
replacer
|
|
1801
|
+
);
|
|
1802
|
+
};
|
|
860
1803
|
|
|
861
|
-
|
|
862
|
-
|
|
1804
|
+
/**
|
|
1805
|
+
* Parse all HTML elements in template and extract their attributes.
|
|
1806
|
+
* @param {string} template Template string with placeholders.
|
|
1807
|
+
* @param {Array} expressions Array of expressions.
|
|
1808
|
+
* @param {Array} elements Array to store element references.
|
|
1809
|
+
* @return {string} Template with parsed attributes.
|
|
1810
|
+
* @throws {SyntaxError} If the template does not have a single root element or is a container component.
|
|
1811
|
+
* @private
|
|
1812
|
+
*/
|
|
1813
|
+
const parseElements = (template, expressions, elements) => {
|
|
1814
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1815
|
+
// Check if template is a container (single placeholder, no tag).
|
|
1816
|
+
const containerMatch = template.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
1817
|
+
if (containerMatch) return template;
|
|
1818
|
+
// Validate that template has a root element.
|
|
1819
|
+
const rootElementMatch = template.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`));
|
|
1820
|
+
if (!rootElementMatch) throw new SyntaxError(`Template must have a single root element or be a container component: "${template.trim()}"`);
|
|
1821
|
+
|
|
1822
|
+
let elementUid = 0;
|
|
1823
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
1824
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
1825
|
+
const isRoot = elementUid === 0;
|
|
1826
|
+
const currentElementUid = ++elementUid;
|
|
1827
|
+
// If there are no dynamic attributes, return original match.
|
|
1828
|
+
if (!isRoot && !attributesStr.match(new RegExp(PH))) {
|
|
1829
|
+
return match;
|
|
1830
|
+
}
|
|
1831
|
+
// Parse attributes.
|
|
1832
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
1833
|
+
// Create element reference.
|
|
1834
|
+
const generateElementUid = componentUid => `${componentUid}-${currentElementUid}`;
|
|
1835
|
+
// Create function that returns attributes object.
|
|
1836
|
+
const getAttributes = function() {
|
|
1837
|
+
// Expand attributes and events.
|
|
1838
|
+
const attributes = expandEvents(
|
|
1839
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
1840
|
+
this.eventsManager
|
|
1841
|
+
);
|
|
1842
|
+
// Extend template attributes with `options.attributes`.
|
|
1843
|
+
if (isRoot && this.attributes) {
|
|
1844
|
+
Object.assign(attributes, getResult(this.attributes, this));
|
|
1845
|
+
}
|
|
1846
|
+
// Add data attribute for element identification.
|
|
1847
|
+
// First element gets the component uid, others get element uid.
|
|
1848
|
+
attributes[Component.ATTRIBUTE_ELEMENT] = generateElementUid(this.uid);
|
|
1849
|
+
|
|
1850
|
+
return attributes;
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
const getSelector = function() {
|
|
1854
|
+
return `[${Component.ATTRIBUTE_ELEMENT}="${generateElementUid(this.uid)}"]`;
|
|
1855
|
+
};
|
|
1856
|
+
// Add element reference to elements array.
|
|
1857
|
+
elements.push({
|
|
1858
|
+
getSelector,
|
|
1859
|
+
getAttributes,
|
|
1860
|
+
});
|
|
1861
|
+
// Add new expression to expressions array.
|
|
1862
|
+
expressions.push(function() {
|
|
1863
|
+
const attributes = getAttributes.call(this);
|
|
1864
|
+
return Component.markAsSafeHTML(getAttributesHTML(attributes));
|
|
1865
|
+
});
|
|
1866
|
+
// Replace attributes with placeholder.
|
|
1867
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
1868
|
+
// Preserve original tag ending (> or />)
|
|
1869
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
1870
|
+
});
|
|
1871
|
+
};
|
|
863
1872
|
|
|
864
|
-
|
|
1873
|
+
/**
|
|
1874
|
+
* Parse elements in partial template.
|
|
1875
|
+
* @param {string} template Template string with placeholders.
|
|
1876
|
+
* @param {Array} expressions Array of expressions.
|
|
1877
|
+
* @return {string} Template with parsed attributes.
|
|
1878
|
+
* @private
|
|
1879
|
+
*/
|
|
1880
|
+
const parsePartialElements = (template, expressions) => {
|
|
1881
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1882
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
1883
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
1884
|
+
// If there are no dynamic attributes, return original match.
|
|
1885
|
+
if (!attributesStr.match(new RegExp(PH))) {
|
|
1886
|
+
return match;
|
|
1887
|
+
}
|
|
1888
|
+
// Parse attributes.
|
|
1889
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
1890
|
+
// Create function that returns attributes object.
|
|
1891
|
+
const getAttributes = function() {
|
|
1892
|
+
const attributes = expandEvents(
|
|
1893
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
1894
|
+
this.eventsManager
|
|
1895
|
+
);
|
|
1896
|
+
|
|
1897
|
+
return attributes;
|
|
1898
|
+
};
|
|
1899
|
+
// Add new expression to expressions array.
|
|
1900
|
+
expressions.push(function() {
|
|
1901
|
+
const attributes = getAttributes.call(this);
|
|
1902
|
+
return Component.markAsSafeHTML(getAttributesHTML(attributes));
|
|
1903
|
+
});
|
|
1904
|
+
// Replace attributes with placeholder.
|
|
1905
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
1906
|
+
// Preserve original tag ending (> or />)
|
|
1907
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
1908
|
+
});
|
|
1909
|
+
};
|
|
865
1910
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1911
|
+
/**
|
|
1912
|
+
* Parse all interpolations in template text content.
|
|
1913
|
+
* @param {string} template Template string with placeholders.
|
|
1914
|
+
* @param {Array} expressions Array of expressions.
|
|
1915
|
+
* @param {Array} interpolations Array to store interpolation references.
|
|
1916
|
+
* @return {string} Template with interpolation markers.
|
|
1917
|
+
* @private
|
|
1918
|
+
*/
|
|
1919
|
+
const parseInterpolations = (template, expressions, interpolations) => {
|
|
1920
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1921
|
+
let interpolationUid = 0;
|
|
1922
|
+
// Match all expression placeholders.
|
|
1923
|
+
return template.replace(
|
|
1924
|
+
new RegExp(PH, 'g'),
|
|
1925
|
+
function(match, expressionIndex, offset) {
|
|
1926
|
+
// Check if this placeholder is inside an element tag (attribute).
|
|
1927
|
+
// `offset` is the index of the match in the original string.
|
|
1928
|
+
const beforeMatch = template.substring(0, offset);
|
|
1929
|
+
const lastOpenTag = beforeMatch.lastIndexOf('<');
|
|
1930
|
+
const lastCloseTag = beforeMatch.lastIndexOf('>');
|
|
1931
|
+
// If we're inside an element tag, don't process as interpolation.
|
|
1932
|
+
if (lastOpenTag > lastCloseTag) {
|
|
1933
|
+
return match;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
const currentInterpolationUid = ++interpolationUid;
|
|
1937
|
+
|
|
1938
|
+
function getStart() {
|
|
1939
|
+
return Component.MARKER_START(`${this.uid}-${currentInterpolationUid}`);
|
|
1940
|
+
}
|
|
1941
|
+
function getEnd() {
|
|
1942
|
+
return Component.MARKER_END(`${this.uid}-${currentInterpolationUid}`);
|
|
1943
|
+
}
|
|
1944
|
+
// Add interpolation reference to interpolations array.
|
|
1945
|
+
interpolations.push({
|
|
1946
|
+
getStart,
|
|
1947
|
+
getEnd,
|
|
1948
|
+
expression : expressions[expressionIndex]
|
|
1949
|
+
});
|
|
1950
|
+
// Add new expression to expressions array.
|
|
1951
|
+
expressions.push(function() {
|
|
1952
|
+
const result = getExpressionResult(expressions[expressionIndex], this);
|
|
1953
|
+
const uid = `${this.uid}-${currentInterpolationUid}`;
|
|
1954
|
+
return new InterpolationWrapper(uid, result);
|
|
1955
|
+
});
|
|
1956
|
+
// Replace with new placeholder.
|
|
1957
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
1958
|
+
}
|
|
1959
|
+
);
|
|
1960
|
+
};
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* Parse attributes string to extract dynamic attributes.
|
|
1964
|
+
* @param {string} attributesStr Attributes string from HTML element.
|
|
1965
|
+
* @param {Array} expressions Array of expressions.
|
|
1966
|
+
* @return {Array} Array of attribute pairs [key, value] or [key, value, hasQuotes].
|
|
1967
|
+
* @private
|
|
1968
|
+
*/
|
|
1969
|
+
const parseAttributes = (attributesStr, expressions) => {
|
|
1970
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1971
|
+
const attributes = [];
|
|
1972
|
+
// Parse attributes string with support for placeholders in both names and values.
|
|
879
1973
|
const regExp = new RegExp(`(${PH}|[\\w-]+)(?:=(["']?)(?:${PH}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`, 'g');
|
|
880
1974
|
|
|
881
1975
|
let attributeMatch;
|
|
882
1976
|
while ((attributeMatch = regExp.exec(attributesStr)) !== null) {
|
|
883
|
-
const [, attribute, attributeIdx
|
|
1977
|
+
const [, attribute, attributeIdx, quotes, valueIdx, value] = attributeMatch;
|
|
884
1978
|
|
|
885
|
-
const attr = typeof attributeIdx !== 'undefined' ? expressions[attributeIdx] : attribute;
|
|
886
|
-
const val = typeof valueIdx !== 'undefined' ? expressions[valueIdx] : value;
|
|
1979
|
+
const attr = typeof attributeIdx !== 'undefined' ? expressions[parseInt(attributeIdx, 10)] : attribute;
|
|
1980
|
+
const val = typeof valueIdx !== 'undefined' ? expressions[parseInt(valueIdx, 10)] : value;
|
|
887
1981
|
|
|
888
1982
|
if (typeof val !== 'undefined') {
|
|
889
|
-
|
|
1983
|
+
attributes.push([attr, val, !!quotes]);
|
|
890
1984
|
} else {
|
|
891
|
-
|
|
1985
|
+
attributes.push([attr]);
|
|
892
1986
|
}
|
|
893
1987
|
}
|
|
894
1988
|
|
|
895
|
-
return
|
|
896
|
-
};
|
|
897
|
-
|
|
898
|
-
/*
|
|
899
|
-
* HTML tags that are self closing.
|
|
900
|
-
*/
|
|
901
|
-
const selfClosingTags = {
|
|
902
|
-
area : true, base : true, br : true, col : true, embed : true, hr : true,
|
|
903
|
-
img : true, input : true, link : true, meta : true, source : true, track : true, wbr : true
|
|
1989
|
+
return attributes;
|
|
904
1990
|
};
|
|
905
1991
|
|
|
906
1992
|
/*
|
|
907
1993
|
* These option keys will be extended on the component instance.
|
|
908
1994
|
*/
|
|
909
|
-
const componentOptions = ['key', 'state', 'onCreate', 'onChange', '
|
|
1995
|
+
const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onHydrate', 'onRecycle', 'onUpdate'];
|
|
910
1996
|
|
|
911
1997
|
/**
|
|
912
|
-
*
|
|
913
|
-
* making it simple to add child views and build complex user interfaces.
|
|
914
|
-
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
915
|
-
* guidelines that allow for a more declarative development style.
|
|
916
|
-
* 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.
|
|
917
|
-
* @module
|
|
918
|
-
* @extends Rasti.View
|
|
919
|
-
* @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onRender, onCreate, onChange.
|
|
920
|
-
* @property {string} key A unique key to identify the component. Used to recycle child components.
|
|
921
|
-
* @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.
|
|
922
|
-
* @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.
|
|
923
|
-
* @see {@link #module_component_create Component.create}
|
|
924
|
-
* @example
|
|
925
|
-
* import { Component, Model } from 'rasti';
|
|
926
|
-
* // Create Timer component.
|
|
927
|
-
* const Timer = Component.create`
|
|
928
|
-
* <div>
|
|
929
|
-
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
930
|
-
* </div>
|
|
931
|
-
* `;
|
|
932
|
-
* // Create model to store seconds.
|
|
933
|
-
* const model = new Model({ seconds: 0 });
|
|
934
|
-
* // Mount timer on body.
|
|
935
|
-
* Timer.mount({ model }, document.body);
|
|
936
|
-
* // Increment `model.seconds` every second.
|
|
937
|
-
* setInterval(() => model.seconds++, 1000);
|
|
1998
|
+
* @lends module:Component
|
|
938
1999
|
*/
|
|
939
2000
|
class Component extends View {
|
|
940
2001
|
constructor(options = {}) {
|
|
941
2002
|
super(...arguments);
|
|
2003
|
+
this.componentOptions = [];
|
|
942
2004
|
// Extend "this" with options.
|
|
943
2005
|
componentOptions.forEach(key => {
|
|
944
|
-
if (key in options)
|
|
2006
|
+
if (key in options) {
|
|
2007
|
+
this[key] = options[key];
|
|
2008
|
+
this.componentOptions.push(key);
|
|
2009
|
+
}
|
|
945
2010
|
});
|
|
2011
|
+
// Extract props from options that aren't component or view options.
|
|
2012
|
+
const props = {};
|
|
2013
|
+
Object.keys(options).forEach(key => {
|
|
2014
|
+
if (!this.viewOptions.includes(key) && !this.componentOptions.includes(key)) {
|
|
2015
|
+
props[key] = options[key];
|
|
2016
|
+
}
|
|
2017
|
+
});
|
|
2018
|
+
// Store props as Model for reactive updates.
|
|
2019
|
+
this.props = new Model(props);
|
|
946
2020
|
// Store options by default.
|
|
947
2021
|
this.options = options;
|
|
948
2022
|
// Bind `partial` method to `this`.
|
|
949
2023
|
this.partial = this.partial.bind(this);
|
|
2024
|
+
// Bind `onChange` method to `this`.
|
|
2025
|
+
this.onChange = this.onChange.bind(this);
|
|
950
2026
|
// Call lifecycle method.
|
|
951
2027
|
this.onCreate.apply(this, arguments);
|
|
952
2028
|
}
|
|
953
2029
|
|
|
954
2030
|
/**
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
* @param {Rasti.Model} model A model or emitter object to listen to changes.
|
|
959
|
-
* @return {Rasti.Component} The component instance.
|
|
2031
|
+
* Get events object for automatic event delegation, based on data attributes.
|
|
2032
|
+
* @return {object} The events object.
|
|
2033
|
+
* @private
|
|
960
2034
|
*/
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
2035
|
+
events() {
|
|
2036
|
+
const events = {};
|
|
2037
|
+
// Create events object.
|
|
2038
|
+
this.eventsManager.types.forEach(type => {
|
|
2039
|
+
const dataAttribute = Component.ATTRIBUTE_EVENT(type);
|
|
2040
|
+
// Create a listener function that gets the listener index from the data attribute and calls the listener.
|
|
2041
|
+
const listener = function(event, component, matched) {
|
|
2042
|
+
// Get the listener index from the data attribute.
|
|
2043
|
+
const index = matched.getAttribute(dataAttribute);
|
|
2044
|
+
// Root element listener may not have a data attribute.
|
|
2045
|
+
if (index) {
|
|
2046
|
+
let currentListener = this.eventsManager.listeners[parseInt(index, 10)];
|
|
2047
|
+
if (typeof currentListener === 'string') currentListener = this[currentListener];
|
|
2048
|
+
validateListener(currentListener);
|
|
2049
|
+
// Call the listener.
|
|
2050
|
+
currentListener.call(this, event, component, matched);
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
// Add an event listener to the events object for each event type, using the data attribute
|
|
2054
|
+
// as both a CSS selector and to store the listener's index.
|
|
2055
|
+
events[`${type} [${dataAttribute}]`] = listener;
|
|
2056
|
+
// Add an event listener to the events object for each event type that matches the root element.
|
|
2057
|
+
events[type] = listener;
|
|
2058
|
+
});
|
|
975
2059
|
|
|
2060
|
+
return events;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
/**
|
|
2064
|
+
* Subscribes to a `change` event on a model or emitter object and invokes the `onChange` lifecycle method.
|
|
2065
|
+
* The subscription is automatically cleaned up when the component is destroyed.
|
|
2066
|
+
* By default, the component subscribes to changes on `this.model`, `this.state`, and `this.props`.
|
|
2067
|
+
*
|
|
2068
|
+
* @param {Object} model - The model or emitter object to listen to.
|
|
2069
|
+
* @param {string} [type='change'] - The event type to listen for.
|
|
2070
|
+
* @param {Function} [listener=this.onChange] - The callback to invoke when the event is emitted.
|
|
2071
|
+
* @returns {Component} The current component instance for chaining.
|
|
2072
|
+
*/
|
|
2073
|
+
subscribe(model, type = 'change', listener = this.onChange) {
|
|
2074
|
+
// Check if model has `on` method.
|
|
2075
|
+
if (model.on) this.listenTo(model, type, listener);
|
|
976
2076
|
return this;
|
|
977
2077
|
}
|
|
978
2078
|
|
|
@@ -985,7 +2085,7 @@
|
|
|
985
2085
|
* @private
|
|
986
2086
|
*/
|
|
987
2087
|
isContainer() {
|
|
988
|
-
return
|
|
2088
|
+
return this.template.elements.length === 0 && this.template.interpolations.length === 1;
|
|
989
2089
|
}
|
|
990
2090
|
|
|
991
2091
|
/**
|
|
@@ -994,134 +2094,107 @@
|
|
|
994
2094
|
* @private
|
|
995
2095
|
*/
|
|
996
2096
|
ensureElement() {
|
|
2097
|
+
// Store data event listeners.
|
|
2098
|
+
this.eventsManager = new EventsManager();
|
|
2099
|
+
// Store position tracking for recycling.
|
|
2100
|
+
this.pathManager = new PathManager();
|
|
2101
|
+
// Call template function.
|
|
2102
|
+
this.template = getResult(this.template, this);
|
|
997
2103
|
// If el is provided, delegate events.
|
|
998
2104
|
if (this.el) {
|
|
999
2105
|
// If "this.el" is a function, call it to get the element.
|
|
1000
2106
|
this.el = getResult(this.el, this);
|
|
1001
|
-
|
|
2107
|
+
// Render the component as a string to generate children components.
|
|
2108
|
+
this.toString();
|
|
2109
|
+
// Hydrate the component.
|
|
2110
|
+
this.hydrate(this.el.parentNode);
|
|
1002
2111
|
}
|
|
1003
2112
|
}
|
|
1004
2113
|
|
|
1005
|
-
/**
|
|
1006
|
-
* Locate the root element of the `Component` within a specified parent node.
|
|
1007
|
-
* This is achieved by searching for the element using the unique data attribute assigned to the `Component`.
|
|
1008
|
-
* @param {Node} parent - The parent node to search within.
|
|
1009
|
-
* @return {Node} The root element of the component, or `null` if not found.
|
|
1010
|
-
* @private
|
|
1011
|
-
*/
|
|
1012
|
-
findElement(parent) {
|
|
1013
|
-
return (parent || document).querySelector(`[${Component.DATA_ATTRIBUTE_UID}="${this.uid}"]`);
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
/**
|
|
1017
|
-
* Retrieve the attributes to be applied to the element.
|
|
1018
|
-
* This includes attributes to be added, removed, and their HTML representation.
|
|
1019
|
-
* @return {object} An object containing the following properties:
|
|
1020
|
-
* @property {object} add - Attributes to be added to the element, with their values.
|
|
1021
|
-
* @property {object} remove - Attributes to be removed from the element.
|
|
1022
|
-
* @property {string} html - A string representation of the attributes for use in HTML.
|
|
1023
|
-
* @private
|
|
1024
|
-
*/
|
|
1025
|
-
getAttributes() {
|
|
1026
|
-
const add = {};
|
|
1027
|
-
const remove = {};
|
|
1028
|
-
const html = [];
|
|
1029
|
-
|
|
1030
|
-
const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
|
|
1031
|
-
|
|
1032
|
-
if (this.attributes) Object.assign(attributes, getResult(this.attributes, this));
|
|
1033
|
-
// Store previous attributes.
|
|
1034
|
-
const previousAttributes = this.previousAttributes || {};
|
|
1035
|
-
this.previousAttributes = attributes;
|
|
1036
|
-
|
|
1037
|
-
Object.keys(attributes).forEach(key => {
|
|
1038
|
-
let value = attributes[key];
|
|
1039
|
-
// Transform bool attribute values
|
|
1040
|
-
if (value === false) {
|
|
1041
|
-
remove[key] = true;
|
|
1042
|
-
} else if (value === true) {
|
|
1043
|
-
add[key] = '';
|
|
1044
|
-
html.push(key);
|
|
1045
|
-
} else {
|
|
1046
|
-
if (value === null || typeof value === 'undefined') value = '';
|
|
1047
|
-
|
|
1048
|
-
add[key] = value;
|
|
1049
|
-
html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
1050
|
-
}
|
|
1051
|
-
});
|
|
1052
|
-
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
1053
|
-
Object.keys(previousAttributes).forEach(key => {
|
|
1054
|
-
if (!(key in attributes)) {
|
|
1055
|
-
remove[key] = true;
|
|
1056
|
-
}
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
return { add, remove, html : html.join(' ') };
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
2114
|
/**
|
|
1063
2115
|
* Used internally on the render process.
|
|
1064
2116
|
* Attach the `Component` to the dom element providing `this.el`, delegate events,
|
|
1065
|
-
* subscribe to model changes and call `
|
|
2117
|
+
* subscribe to model changes and call `onHydrate` lifecycle method.
|
|
1066
2118
|
* @param parent {node} The parent node.
|
|
1067
|
-
* @return {
|
|
2119
|
+
* @return {Component} The component instance.
|
|
1068
2120
|
* @private
|
|
1069
2121
|
*/
|
|
1070
2122
|
hydrate(parent) {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
if (this.state) this.subscribe(this.state);
|
|
2123
|
+
['model', 'state', 'props'].forEach(key => {
|
|
2124
|
+
if (this[key]) this.subscribe(this[key]);
|
|
2125
|
+
});
|
|
1075
2126
|
|
|
1076
2127
|
if (this.isContainer()) {
|
|
2128
|
+
// Get references for interpolation marker comments
|
|
2129
|
+
this.template.interpolations[0].hydrate(parent);
|
|
2130
|
+
// Call hydrate on children.
|
|
1077
2131
|
this.children[0].hydrate(parent);
|
|
2132
|
+
// Set the first element as the component's element.
|
|
1078
2133
|
this.el = this.children[0].el;
|
|
1079
2134
|
} else {
|
|
1080
|
-
|
|
2135
|
+
// Search for every element in template using getSelector
|
|
2136
|
+
this.template.elements.forEach((element, index) => {
|
|
2137
|
+
if (index === 0) {
|
|
2138
|
+
element.hydrate(parent);
|
|
2139
|
+
if (this.el) element.ref = this.el;
|
|
2140
|
+
else this.el = element.ref;
|
|
2141
|
+
}
|
|
2142
|
+
else {
|
|
2143
|
+
element.hydrate(this.el);
|
|
2144
|
+
}
|
|
2145
|
+
});
|
|
2146
|
+
// Delegate events.
|
|
1081
2147
|
this.delegateEvents();
|
|
2148
|
+
// Get references for interpolation marker comments
|
|
2149
|
+
this.template.interpolations.forEach(interpolation => interpolation.hydrate(this.el));
|
|
1082
2150
|
this.children.forEach(child => child.hydrate(this.el));
|
|
1083
2151
|
}
|
|
1084
|
-
// Call `
|
|
1085
|
-
this.
|
|
2152
|
+
// Call `onHydrate` lifecycle method.
|
|
2153
|
+
this.onHydrate.call(this);
|
|
1086
2154
|
// Return `this` for chaining.
|
|
1087
2155
|
return this;
|
|
1088
2156
|
}
|
|
1089
2157
|
|
|
1090
2158
|
/**
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
* @param parent {node} The parent node.
|
|
1095
|
-
* @return {Rasti.Component} The component instance.
|
|
2159
|
+
* Get a `comment` marker with same data attribute as this component.
|
|
2160
|
+
* Used to replace the component when it is recycled.
|
|
2161
|
+
* @return {string} The recycle placeholder.
|
|
1096
2162
|
* @private
|
|
1097
2163
|
*/
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
if (this.isContainer()) {
|
|
1101
|
-
this.children[0].recycle(parent);
|
|
1102
|
-
} else {
|
|
1103
|
-
// Find placeholder element to be replaced. It has same data attribute as this component.
|
|
1104
|
-
const toBeReplaced = this.findElement(parent);
|
|
1105
|
-
// Replace it with this.el.
|
|
1106
|
-
toBeReplaced.replaceWith(this.el);
|
|
1107
|
-
}
|
|
1108
|
-
// Call `onRender` lifecycle method.
|
|
1109
|
-
this.onRender.call(this, Component.RENDER_TYPE_RECYCLE);
|
|
1110
|
-
// Return `this` for chaining.
|
|
1111
|
-
return this;
|
|
2164
|
+
getRecycledMarker() {
|
|
2165
|
+
return `<!--${Component.MARKER_RECYCLED(this.uid)}-->`;
|
|
1112
2166
|
}
|
|
1113
2167
|
|
|
1114
2168
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
2169
|
+
* Get the component nodes to be inserted into the DOM.
|
|
2170
|
+
* Used internally during the render process, you usually don't need to call it if you use
|
|
2171
|
+
* `mount()`.
|
|
2172
|
+
* For components that render HTML elements you can safely rely on `this.el`.
|
|
2173
|
+
* For container components that render another component you need the wrapper nodes to insert
|
|
2174
|
+
* them into the DOM; use `getNodes()` for that.
|
|
2175
|
+
* @return {Node[]} The component nodes.
|
|
1119
2176
|
*/
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
2177
|
+
getNodes() {
|
|
2178
|
+
return this.isContainer() ?
|
|
2179
|
+
[this.template.interpolations[0].ref[0], ...this.children[0].getNodes(), this.template.interpolations[0].ref[1]] :
|
|
2180
|
+
[this.el];
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
/**
|
|
2184
|
+
* Used internally on the render process.
|
|
2185
|
+
* Reuse a `Component` by replacing the placeholder comment with the real nodes.
|
|
2186
|
+
* Call `onRecycle` lifecycle method.
|
|
2187
|
+
* @param parent {node} The parent node.
|
|
2188
|
+
* @return {Component} The component instance.
|
|
2189
|
+
* @private
|
|
2190
|
+
*/
|
|
2191
|
+
recycle(parent) {
|
|
2192
|
+
// Locate the placeholder comment and replace it with the real nodes
|
|
2193
|
+
const toBeReplaced = findComment(parent, Component.MARKER_RECYCLED(this.uid), isComponent);
|
|
2194
|
+
// Replace it with this.el.
|
|
2195
|
+
toBeReplaced.replaceWith(...this.getNodes());
|
|
2196
|
+
// Call `onRecycle` lifecycle method.
|
|
2197
|
+
this.onRecycle.call(this);
|
|
1125
2198
|
// Return `this` for chaining.
|
|
1126
2199
|
return this;
|
|
1127
2200
|
}
|
|
@@ -1147,16 +2220,22 @@
|
|
|
1147
2220
|
}
|
|
1148
2221
|
|
|
1149
2222
|
/**
|
|
1150
|
-
* Lifecycle method. Called
|
|
1151
|
-
* - When the component is rendered for the first time, this method is called with `Component.RENDER_TYPE_HYDRATE` as the argument.
|
|
1152
|
-
* - When the component is updated or re-rendered, this method is called with `Component.RENDER_TYPE_RENDER` as the argument.
|
|
1153
|
-
* - When the component is recycled (reused with the same key), this method is called with `Component.RENDER_TYPE_RECYCLE` as the argument.
|
|
1154
|
-
* @param {string} type - The render type. Possible values are: `Component.RENDER_TYPE_HYDRATE`, `Component.RENDER_TYPE_RENDER` and `Component.RENDER_TYPE_RECYCLE`.
|
|
2223
|
+
* Lifecycle method. Called when the component is rendered for the first time and hydrated.
|
|
1155
2224
|
*/
|
|
1156
|
-
|
|
2225
|
+
onHydrate() {}
|
|
1157
2226
|
|
|
1158
2227
|
/**
|
|
1159
|
-
* Lifecycle method. Called when the
|
|
2228
|
+
* Lifecycle method. Called when the component is recycled (reused with the same key) and added to the DOM again.
|
|
2229
|
+
*/
|
|
2230
|
+
onRecycle() {}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* Lifecycle method. Called when the component is updated or re-rendered.
|
|
2234
|
+
*/
|
|
2235
|
+
onUpdate() {}
|
|
2236
|
+
|
|
2237
|
+
/**
|
|
2238
|
+
* Lifecycle method. Called when the component is destroyed.
|
|
1160
2239
|
* @param {object} options Options object or any arguments passed to `destroy` method.
|
|
1161
2240
|
*/
|
|
1162
2241
|
onDestroy() {}
|
|
@@ -1164,18 +2243,18 @@
|
|
|
1164
2243
|
/**
|
|
1165
2244
|
* Tagged template helper method.
|
|
1166
2245
|
* Used to create a partial template.
|
|
1167
|
-
* It will return a
|
|
2246
|
+
* It will return a Partial object that preserves structure for position-based recycling.
|
|
1168
2247
|
* Components will be added as children by the parent component. Template strings literals
|
|
1169
2248
|
* will be marked as safe HTML to be rendered.
|
|
1170
2249
|
* This method is bound to the component instance by default.
|
|
1171
2250
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
1172
2251
|
* @param {...any} expressions - Template expressions.
|
|
1173
|
-
* @return {
|
|
2252
|
+
* @return {Partial} Partial object containing strings and expressions.
|
|
1174
2253
|
* @example
|
|
1175
2254
|
* import { Component } from 'rasti';
|
|
1176
2255
|
* // Create a Title component.
|
|
1177
2256
|
* const Title = Component.create`
|
|
1178
|
-
* <h1>${
|
|
2257
|
+
* <h1>${({ props }) => props.children}</h1>
|
|
1179
2258
|
* `;
|
|
1180
2259
|
* // Create Main component.
|
|
1181
2260
|
* const Main = Component.create`
|
|
@@ -1195,147 +2274,191 @@
|
|
|
1195
2274
|
* });
|
|
1196
2275
|
*/
|
|
1197
2276
|
partial(strings, ...expressions) {
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
expandComponents(
|
|
1201
|
-
|
|
1202
|
-
|
|
2277
|
+
const items = splitPlaceholders(
|
|
2278
|
+
parsePartialElements(
|
|
2279
|
+
expandComponents(
|
|
2280
|
+
addPlaceholders(strings, expressions),
|
|
2281
|
+
expressions
|
|
2282
|
+
),
|
|
2283
|
+
expressions
|
|
2284
|
+
),
|
|
2285
|
+
expressions
|
|
2286
|
+
).map(item => getExpressionResult(item, this));
|
|
2287
|
+
|
|
2288
|
+
return new Partial(items);
|
|
1203
2289
|
}
|
|
1204
2290
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
2291
|
+
/**
|
|
2292
|
+
* Render a template part.
|
|
2293
|
+
* @param {any} part - The template part.
|
|
2294
|
+
* @param {function} addChild - The addChild function.
|
|
2295
|
+
* @return {string} The rendered template part.
|
|
2296
|
+
* @private
|
|
2297
|
+
*/
|
|
2298
|
+
renderTemplatePart(part, addChild) {
|
|
2299
|
+
const result = getExpressionResult(part, this);
|
|
2300
|
+
|
|
2301
|
+
const parse = item => {
|
|
2302
|
+
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
2303
|
+
if (item instanceof SafeHTML) return item;
|
|
2304
|
+
if (item instanceof Component) return addChild(item);
|
|
2305
|
+
|
|
2306
|
+
if (item instanceof Partial) {
|
|
2307
|
+
this.pathManager.push();
|
|
2308
|
+
const out = item.items.map(subItem => { this.pathManager.increment(); return parse(subItem); }).join('');
|
|
2309
|
+
this.pathManager.pop();
|
|
2310
|
+
return out;
|
|
2311
|
+
}
|
|
2312
|
+
// Handle arrays (user loops) - disable tracking.
|
|
2313
|
+
if (Array.isArray(item)) {
|
|
2314
|
+
this.pathManager.pause();
|
|
2315
|
+
const out = deepFlat(item).map(parse).join('');
|
|
2316
|
+
this.pathManager.resume();
|
|
2317
|
+
return out;
|
|
2318
|
+
}
|
|
2319
|
+
// InterpolationWrapper: add markers and process maintaining tracking.
|
|
2320
|
+
if (item instanceof InterpolationWrapper) {
|
|
2321
|
+
this.pathManager.increment();
|
|
2322
|
+
const startMarker = `<!--${Component.MARKER_START(item.interpolationUid)}-->`;
|
|
2323
|
+
const endMarker = `<!--${Component.MARKER_END(item.interpolationUid)}-->`;
|
|
2324
|
+
return `${startMarker}${parse(item.result)}${endMarker}`;
|
|
2325
|
+
}
|
|
2326
|
+
|
|
2327
|
+
return Component.sanitize(item);
|
|
2328
|
+
}
|
|
2329
|
+
// Return empty string if item is undefined, null, false, or true.
|
|
2330
|
+
return '';
|
|
2331
|
+
};
|
|
1210
2332
|
|
|
1211
|
-
return
|
|
1212
|
-
`<${tag} ${attributes}></${tag}>` :
|
|
1213
|
-
`<${tag} ${attributes} />`;
|
|
2333
|
+
return `${parse(result)}`;
|
|
1214
2334
|
}
|
|
1215
2335
|
|
|
1216
|
-
|
|
1217
|
-
*
|
|
2336
|
+
/**
|
|
2337
|
+
* Render the component as a string.
|
|
2338
|
+
* Used internally on the render process.
|
|
2339
|
+
* Use it for server-side rendering or static site generation.
|
|
2340
|
+
* @return {string} The rendered component.
|
|
1218
2341
|
*/
|
|
1219
2342
|
toString() {
|
|
1220
2343
|
// Normally there won't be any children, but if there are, destroy them.
|
|
1221
2344
|
this.destroyChildren();
|
|
1222
|
-
//
|
|
1223
|
-
|
|
1224
|
-
//
|
|
1225
|
-
|
|
1226
|
-
//
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
2345
|
+
// Normally there won't be any data event listeners, but if there are, clear them.
|
|
2346
|
+
this.eventsManager.reset();
|
|
2347
|
+
// Reset position tracking.
|
|
2348
|
+
this.pathManager.reset();
|
|
2349
|
+
// Bind addChild method.
|
|
2350
|
+
const addChild = component => {
|
|
2351
|
+
this.pathManager.track(component);
|
|
2352
|
+
return this.addChild(component);
|
|
2353
|
+
};
|
|
2354
|
+
// Render the template parts.
|
|
2355
|
+
return this.template.parts
|
|
2356
|
+
.map(part => this.renderTemplatePart(part, addChild))
|
|
2357
|
+
.join('');
|
|
1234
2358
|
}
|
|
1235
2359
|
|
|
1236
2360
|
/**
|
|
1237
2361
|
* Render the `Component`.
|
|
1238
|
-
* - If `this.el` is not present, the `Component` will be rendered as a string inside a `DocumentFragment` and hydrated, making `this.el` available. The `
|
|
1239
|
-
* - 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 `
|
|
1240
|
-
* - When rendering child components,
|
|
2362
|
+
* - 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.
|
|
2363
|
+
* - 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.
|
|
2364
|
+
* - When rendering child components, recycling happens in two ways:
|
|
2365
|
+
* - Components with a `key` are recycled if a previous child with the same key exists.
|
|
2366
|
+
* - Unkeyed components are recycled if they have the same type and position in the template or partial.
|
|
2367
|
+
* A recycled `Component` will call the `onRecycle` lifecycle method.
|
|
1241
2368
|
* - If the active element is inside the component, it will retain focus after the render.
|
|
1242
|
-
* @return {
|
|
2369
|
+
* @return {Component} The component instance.
|
|
1243
2370
|
*/
|
|
1244
2371
|
render() {
|
|
1245
2372
|
// Prevent a last re render if view is already destroyed.
|
|
1246
2373
|
if (this.destroyed) return this;
|
|
1247
2374
|
// If `this.el` is not present, render the view as a string and hydrate it.
|
|
1248
2375
|
if (!this.el) {
|
|
1249
|
-
const fragment = this
|
|
1250
|
-
fragment
|
|
1251
|
-
this.hydrate(fragment.content);
|
|
2376
|
+
const fragment = parseHTML(this);
|
|
2377
|
+
this.hydrate(fragment);
|
|
1252
2378
|
return this;
|
|
1253
2379
|
}
|
|
1254
|
-
//
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
//
|
|
1267
|
-
|
|
1268
|
-
// Store active element.
|
|
1269
|
-
const activeElement = document.activeElement;
|
|
1270
|
-
|
|
2380
|
+
// Clear event listeners.
|
|
2381
|
+
this.eventsManager.reset();
|
|
2382
|
+
// Reset position tracking.
|
|
2383
|
+
this.pathManager.reset();
|
|
2384
|
+
// Store active element.
|
|
2385
|
+
const activeElement = this.isContainer() ? null : document.activeElement;
|
|
2386
|
+
// Update elements.
|
|
2387
|
+
this.template.elements.forEach(element => element.update());
|
|
2388
|
+
// Store previous children.
|
|
2389
|
+
const previousChildren = this.children;
|
|
2390
|
+
// Clear current children.
|
|
2391
|
+
this.children = [];
|
|
2392
|
+
// Update interpolations.
|
|
2393
|
+
this.template.interpolations.forEach(interpolation => {
|
|
1271
2394
|
const nextChildren = [];
|
|
1272
2395
|
const recycledChildren = [];
|
|
1273
2396
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const inner = this.template.call(this, component => {
|
|
2397
|
+
this.pathManager.increment();
|
|
2398
|
+
|
|
2399
|
+
const addChild = component => {
|
|
1278
2400
|
let out = component;
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
2401
|
+
let found = null;
|
|
2402
|
+
// Check if child already exists by key.
|
|
2403
|
+
if (component.key) {
|
|
2404
|
+
found = previousChildren.find(prev => prev.key === component.key);
|
|
2405
|
+
} else {
|
|
2406
|
+
// Find by position and type.
|
|
2407
|
+
found = this.pathManager.findRecyclable(component.constructor);
|
|
2408
|
+
}
|
|
1283
2409
|
|
|
1284
2410
|
if (found) {
|
|
1285
2411
|
// If child already exists, replace it html by its root element.
|
|
1286
|
-
out = found.
|
|
2412
|
+
out = found.getRecycledMarker();
|
|
1287
2413
|
// Add child to recycled children.
|
|
1288
|
-
recycledChildren.push(found);
|
|
1289
|
-
//
|
|
1290
|
-
|
|
2414
|
+
recycledChildren.push([found, component]);
|
|
2415
|
+
// Track the component.
|
|
2416
|
+
if (!found.key) this.pathManager.track(found);
|
|
1291
2417
|
} else {
|
|
1292
|
-
//
|
|
2418
|
+
// Add new component.
|
|
1293
2419
|
nextChildren.push(component);
|
|
2420
|
+
// Track the component.
|
|
2421
|
+
this.pathManager.track(component);
|
|
1294
2422
|
}
|
|
1295
|
-
//
|
|
2423
|
+
// Return the component or placeholder.
|
|
1296
2424
|
return out;
|
|
1297
|
-
}
|
|
2425
|
+
};
|
|
1298
2426
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
// Replace `this.el` with nextEl.
|
|
1308
|
-
this.el.replaceWith(nextEl);
|
|
1309
|
-
// Set `this.el` to nextEl.
|
|
1310
|
-
this.el = nextEl;
|
|
1311
|
-
} else if (recycledChildren[0]) {
|
|
1312
|
-
this.addChild(recycledChildren[0]);
|
|
1313
|
-
} else {
|
|
1314
|
-
throw new Error('Container component must have a child component');
|
|
1315
|
-
}
|
|
1316
|
-
} else {
|
|
1317
|
-
this.el.innerHTML = inner;
|
|
1318
|
-
// Add new children. Hydrate them.
|
|
1319
|
-
nextChildren.forEach(nextChild => {
|
|
1320
|
-
this.addChild(nextChild).hydrate(this.el);
|
|
1321
|
-
});
|
|
1322
|
-
// Replace children root elements with recycled components.
|
|
1323
|
-
recycledChildren.forEach(recycledChild => {
|
|
1324
|
-
this.addChild(recycledChild).recycle(this.el);
|
|
1325
|
-
});
|
|
1326
|
-
}
|
|
1327
|
-
// Destroy unused children.
|
|
1328
|
-
previousChildren.forEach(previousChild => {
|
|
1329
|
-
const found = recycledChildren.indexOf(previousChild) > -1;
|
|
1330
|
-
if (!found) previousChild.destroy();
|
|
2427
|
+
const fragment = parseHTML(this.renderTemplatePart(interpolation.expression, addChild));
|
|
2428
|
+
// Replace children root elements with recycled components.
|
|
2429
|
+
recycledChildren.forEach(([recycled, discarded]) => {
|
|
2430
|
+
this.addChild(recycled).recycle(fragment);
|
|
2431
|
+
// Update props.
|
|
2432
|
+
recycled.props.set(discarded.props.toJSON());
|
|
2433
|
+
// Destroy discarded component.
|
|
2434
|
+
discarded.destroy();
|
|
1331
2435
|
});
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
2436
|
+
// Add new children. Hydrate them.
|
|
2437
|
+
nextChildren.forEach(child => {
|
|
2438
|
+
this.addChild(child).hydrate(fragment);
|
|
2439
|
+
});
|
|
2440
|
+
|
|
2441
|
+
interpolation.update(fragment);
|
|
2442
|
+
});
|
|
2443
|
+
// Destroy unused children.
|
|
2444
|
+
previousChildren.forEach(prev => {
|
|
2445
|
+
if (this.children.indexOf(prev) < 0) prev.destroy();
|
|
2446
|
+
});
|
|
2447
|
+
// If container, set el to the child element.
|
|
2448
|
+
if (this.isContainer()) {
|
|
2449
|
+
this.el = this.children[0].el;
|
|
2450
|
+
} else {
|
|
2451
|
+
// If there are pending event types, delegate events again.
|
|
2452
|
+
if (this.eventsManager.hasPendingTypes()) {
|
|
2453
|
+
this.delegateEvents();
|
|
1335
2454
|
}
|
|
1336
2455
|
}
|
|
1337
|
-
//
|
|
1338
|
-
this.
|
|
2456
|
+
// Restore focus.
|
|
2457
|
+
if (activeElement && this.el.contains(activeElement)) {
|
|
2458
|
+
activeElement.focus();
|
|
2459
|
+
}
|
|
2460
|
+
// Call onUpdate lifecycle method.
|
|
2461
|
+
this.onUpdate.call(this);
|
|
1339
2462
|
// Return this for chaining.
|
|
1340
2463
|
return this;
|
|
1341
2464
|
}
|
|
@@ -1348,7 +2471,7 @@
|
|
|
1348
2471
|
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
1349
2472
|
* @static
|
|
1350
2473
|
* @param {string} value
|
|
1351
|
-
* @return {
|
|
2474
|
+
* @return {SafeHTML} A safe HTML object.
|
|
1352
2475
|
*/
|
|
1353
2476
|
static markAsSafeHTML(value) {
|
|
1354
2477
|
return new SafeHTML(value);
|
|
@@ -1357,7 +2480,7 @@
|
|
|
1357
2480
|
/**
|
|
1358
2481
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
1359
2482
|
* @static
|
|
1360
|
-
* @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.
|
|
2483
|
+
* @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.
|
|
1361
2484
|
*/
|
|
1362
2485
|
static extend(object) {
|
|
1363
2486
|
const Current = this;
|
|
@@ -1378,27 +2501,28 @@
|
|
|
1378
2501
|
* appends its element into the DOM (if `el` is provided).
|
|
1379
2502
|
* And returns the view instance.
|
|
1380
2503
|
* @static
|
|
1381
|
-
* @param {object} options The view options.
|
|
1382
|
-
* @param {node} el Dom element to append the view element.
|
|
1383
|
-
* @param {boolean} hydrate If true, the view will hydrate existing DOM.
|
|
1384
|
-
* @return {
|
|
2504
|
+
* @param {object} [options] The view options.
|
|
2505
|
+
* @param {node} [el] Dom element to append the view element.
|
|
2506
|
+
* @param {boolean} [hydrate] If true, the view will hydrate existing DOM.
|
|
2507
|
+
* @return {Component} The component instance.
|
|
1385
2508
|
*/
|
|
1386
2509
|
static mount(options, el, hydrate) {
|
|
1387
|
-
// Instantiate
|
|
1388
|
-
const
|
|
2510
|
+
// Instantiate component.
|
|
2511
|
+
const component = new this(options);
|
|
1389
2512
|
// If `el` is passed, mount component.
|
|
1390
2513
|
if (el) {
|
|
1391
2514
|
if (hydrate) {
|
|
2515
|
+
// Generate subcomponents.
|
|
2516
|
+
component.toString();
|
|
1392
2517
|
// Hydrate existing DOM.
|
|
1393
|
-
|
|
1394
|
-
view.hydrate(el);
|
|
2518
|
+
component.hydrate(el);
|
|
1395
2519
|
} else {
|
|
1396
2520
|
// Append element to the DOM.
|
|
1397
|
-
el.
|
|
2521
|
+
el.append(...component.render().getNodes());
|
|
1398
2522
|
}
|
|
1399
2523
|
}
|
|
1400
|
-
// Return
|
|
1401
|
-
return
|
|
2524
|
+
// Return component instance.
|
|
2525
|
+
return component;
|
|
1402
2526
|
}
|
|
1403
2527
|
|
|
1404
2528
|
/**
|
|
@@ -1411,16 +2535,45 @@
|
|
|
1411
2535
|
* - 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.
|
|
1412
2536
|
* ```javascript
|
|
1413
2537
|
* const Button = Component.create`
|
|
1414
|
-
* <button class="${({
|
|
1415
|
-
* ${({
|
|
2538
|
+
* <button class="${({ props }) => props.className}">
|
|
2539
|
+
* ${({ props }) => props.children}
|
|
1416
2540
|
* </button>
|
|
1417
2541
|
* `;
|
|
1418
2542
|
* ```
|
|
1419
|
-
* -
|
|
2543
|
+
* - Attach DOM event handlers per element using camel-cased attributes.
|
|
2544
|
+
* Event handlers are automatically bound to the component instance (`this`).
|
|
2545
|
+
* Internally, Rasti uses event delegation to the component's root element for performance.
|
|
2546
|
+
*
|
|
2547
|
+
* **Attribute Quoting:**
|
|
2548
|
+
* - **Quoted attributes** (`onClick="${handler}"`) evaluate the expression first, useful for dynamic values
|
|
2549
|
+
* - **Unquoted attributes** (`onClick=${handler}`) pass the function reference directly
|
|
2550
|
+
*
|
|
2551
|
+
* **Listener Signature:** `(event, component, matched)`
|
|
2552
|
+
* - `event`: The native DOM event object
|
|
2553
|
+
* - `component`: The component instance (same as `this`)
|
|
2554
|
+
* - `matched`: The element that matched the event (useful for delegation)
|
|
2555
|
+
*
|
|
2556
|
+
* ```javascript
|
|
2557
|
+
* const Button = Component.create`
|
|
2558
|
+
* <button
|
|
2559
|
+
* onClick=${function(event, component, matched) {
|
|
2560
|
+
* // this === component
|
|
2561
|
+
* console.log('Button clicked:', matched);
|
|
2562
|
+
* }}
|
|
2563
|
+
* onMouseOver="${({ model }) => () => model.isHovered = true}"
|
|
2564
|
+
* onMouseOut="${({ model }) => () => model.isHovered = false}"
|
|
2565
|
+
* >
|
|
2566
|
+
* Click me
|
|
2567
|
+
* </button>
|
|
2568
|
+
* `;
|
|
2569
|
+
* ```
|
|
2570
|
+
*
|
|
2571
|
+
* If you need custom delegation (e.g., `{'click .selector': 'handler'}`),
|
|
2572
|
+
* you may override the `events` property as described in {@link #module_view__delegateevents View.delegateEvents}.
|
|
1420
2573
|
* - Boolean attributes should be passed in the format `attribute="${() => true}"`. `false` attributes won't be rendered. `true` attributes will be rendered without a value.
|
|
1421
2574
|
* ```javascript
|
|
1422
2575
|
* const Input = Component.create`
|
|
1423
|
-
* <input type="text" disabled=${({
|
|
2576
|
+
* <input type="text" disabled=${({ props }) => props.disabled} />
|
|
1424
2577
|
* `;
|
|
1425
2578
|
* ```
|
|
1426
2579
|
* - If the interpolated function returns a component instance, it will be added as a child component.
|
|
@@ -1429,21 +2582,21 @@
|
|
|
1429
2582
|
* // Create a button component.
|
|
1430
2583
|
* const Button = Component.create`
|
|
1431
2584
|
* <button class="button">
|
|
1432
|
-
* ${({
|
|
2585
|
+
* ${({ props }) => props.children}
|
|
1433
2586
|
* </button>
|
|
1434
2587
|
* `;
|
|
1435
2588
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1436
2589
|
* const Navigation = Component.create`
|
|
1437
2590
|
* <nav>
|
|
1438
|
-
* ${({
|
|
1439
|
-
* item => Button.mount({
|
|
2591
|
+
* ${({ props }) => props.items.map(
|
|
2592
|
+
* item => Button.mount({ children : item.label })
|
|
1440
2593
|
* )}
|
|
1441
2594
|
* </nav>
|
|
1442
2595
|
* `;
|
|
1443
2596
|
* // Create a header component. Add navigation as a child.
|
|
1444
2597
|
* const Header = Component.create`
|
|
1445
2598
|
* <header>
|
|
1446
|
-
* ${({
|
|
2599
|
+
* ${({ props }) => Navigation.mount({ items : props.items})}
|
|
1447
2600
|
* </header>
|
|
1448
2601
|
* `;
|
|
1449
2602
|
* ```
|
|
@@ -1452,13 +2605,13 @@
|
|
|
1452
2605
|
* // Create a button component.
|
|
1453
2606
|
* const Button = Component.create`
|
|
1454
2607
|
* <button class="button">
|
|
1455
|
-
* ${({
|
|
2608
|
+
* ${({ props }) => props.children}
|
|
1456
2609
|
* </button>
|
|
1457
2610
|
* `;
|
|
1458
2611
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1459
2612
|
* const Navigation = Component.create`
|
|
1460
2613
|
* <nav>
|
|
1461
|
-
* ${({
|
|
2614
|
+
* ${({ props, partial }) => props.items.map(
|
|
1462
2615
|
* item => partial`<${Button}>${item.label}</${Button}>`
|
|
1463
2616
|
* )}
|
|
1464
2617
|
* </nav>
|
|
@@ -1466,7 +2619,7 @@
|
|
|
1466
2619
|
* // Create a header component. Add navigation as a child.
|
|
1467
2620
|
* const Header = Component.create`
|
|
1468
2621
|
* <header>
|
|
1469
|
-
* <${Navigation} items="${({
|
|
2622
|
+
* <${Navigation} items="${({ props }) => props.items}" />
|
|
1470
2623
|
* </header>
|
|
1471
2624
|
* `;
|
|
1472
2625
|
* ```
|
|
@@ -1474,8 +2627,8 @@
|
|
|
1474
2627
|
* ```javascript
|
|
1475
2628
|
* // Create a button component.
|
|
1476
2629
|
* const Button = Component.create`
|
|
1477
|
-
* <button class="${({
|
|
1478
|
-
* ${
|
|
2630
|
+
* <button class="${({ props }) => props.className}">
|
|
2631
|
+
* ${({ props }) => props.children}
|
|
1479
2632
|
* </button>
|
|
1480
2633
|
* `;
|
|
1481
2634
|
* // Create a container that renders a Button component.
|
|
@@ -1484,107 +2637,113 @@
|
|
|
1484
2637
|
* `;
|
|
1485
2638
|
* // Create a container that renders a Button component, using a function.
|
|
1486
2639
|
* const ButtonCancel = Component.create(() => Button.mount({
|
|
1487
|
-
* className: 'cancel',
|
|
1488
|
-
*
|
|
2640
|
+
* className : 'cancel',
|
|
2641
|
+
* children : 'Cancel'
|
|
1489
2642
|
* }));
|
|
1490
2643
|
* ```
|
|
1491
2644
|
* @static
|
|
1492
|
-
* @param {string|
|
|
2645
|
+
* @param {string|Function} strings - HTML template for the component or a function that mounts a sub component.
|
|
1493
2646
|
* @param {...*} expressions - The expressions to be interpolated within the template.
|
|
1494
|
-
* @return {
|
|
2647
|
+
* @return {Component} The newly created component class.
|
|
1495
2648
|
*/
|
|
1496
2649
|
static create(strings, ...expressions) {
|
|
1497
|
-
const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
|
|
1498
2650
|
// Containers can be created using create as a functions instead of a tagged template.
|
|
1499
2651
|
if (typeof strings === 'function') {
|
|
1500
2652
|
expressions = [strings];
|
|
1501
2653
|
strings = ['', ''];
|
|
1502
2654
|
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
2655
|
+
// Create elements, interpolations and parts arrays.
|
|
2656
|
+
const elements = [], interpolations = [];
|
|
2657
|
+
const parts = splitPlaceholders(
|
|
2658
|
+
parseInterpolations(
|
|
2659
|
+
parseElements(
|
|
2660
|
+
expandComponents(
|
|
2661
|
+
addPlaceholders(
|
|
2662
|
+
strings,
|
|
2663
|
+
expressions
|
|
2664
|
+
).trim(),
|
|
2665
|
+
expressions
|
|
2666
|
+
),
|
|
2667
|
+
expressions,
|
|
2668
|
+
elements
|
|
2669
|
+
),
|
|
2670
|
+
expressions,
|
|
2671
|
+
interpolations
|
|
2672
|
+
),
|
|
2673
|
+
expressions
|
|
1510
2674
|
);
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
1528
|
-
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
1529
|
-
|
|
1530
|
-
Object.keys(typeListeners).forEach(selector => {
|
|
1531
|
-
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
1532
|
-
});
|
|
1533
|
-
|
|
1534
|
-
return out;
|
|
1535
|
-
}, {});
|
|
1536
|
-
};
|
|
1537
|
-
// If there is a closing tag, get template.
|
|
1538
|
-
if (close) {
|
|
1539
|
-
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
1540
|
-
template = function(addChild) {
|
|
1541
|
-
return deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
1542
|
-
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
1543
|
-
if (item instanceof SafeHTML) return item;
|
|
1544
|
-
if (item instanceof Component) return addChild(item);
|
|
1545
|
-
return Component.sanitize(item);
|
|
1546
|
-
}
|
|
1547
|
-
return '';
|
|
1548
|
-
}).join('');
|
|
1549
|
-
};
|
|
1550
|
-
}
|
|
1551
|
-
} else {
|
|
1552
|
-
// It's a container.
|
|
1553
|
-
match = main.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
1554
|
-
|
|
1555
|
-
if (match) {
|
|
1556
|
-
// If there is only one expression and no tag, is a container.
|
|
1557
|
-
template = function(addChild) {
|
|
1558
|
-
// Replace expressions.
|
|
1559
|
-
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
2675
|
+
// Create subclass for this component.
|
|
2676
|
+
return this.extend({
|
|
2677
|
+
template() {
|
|
2678
|
+
return {
|
|
2679
|
+
elements : elements.map(element => new Element({
|
|
2680
|
+
getSelector : element.getSelector.bind(this),
|
|
2681
|
+
getAttributes : element.getAttributes.bind(this)
|
|
2682
|
+
})),
|
|
2683
|
+
interpolations : interpolations.map(interpolation => new Interpolation({
|
|
2684
|
+
getStart : interpolation.getStart.bind(this),
|
|
2685
|
+
getEnd : interpolation.getEnd.bind(this),
|
|
2686
|
+
expression : interpolation.expression,
|
|
2687
|
+
isComponent,
|
|
2688
|
+
isElement
|
|
2689
|
+
})),
|
|
2690
|
+
parts,
|
|
1560
2691
|
};
|
|
1561
|
-
} else {
|
|
1562
|
-
throw new SyntaxError('Invalid component');
|
|
1563
2692
|
}
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
const Current = this;
|
|
1567
|
-
// Create subclass for this component.
|
|
1568
|
-
return Current.extend({
|
|
1569
|
-
// Set root element tag.
|
|
1570
|
-
tag,
|
|
1571
|
-
// Set attributes.
|
|
1572
|
-
attributes,
|
|
1573
|
-
// Set events.
|
|
1574
|
-
events,
|
|
1575
|
-
// Set template.
|
|
1576
|
-
template
|
|
1577
2693
|
});
|
|
1578
2694
|
}
|
|
1579
2695
|
}
|
|
1580
2696
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
Component.
|
|
1585
|
-
Component.
|
|
2697
|
+
/*
|
|
2698
|
+
* Attributes used to identify elements and events.
|
|
2699
|
+
*/
|
|
2700
|
+
Component.ATTRIBUTE_ELEMENT = 'data-rasti-el';
|
|
2701
|
+
Component.ATTRIBUTE_EVENT = (type) => `data-rasti-on-${type}`;
|
|
2702
|
+
|
|
2703
|
+
/*
|
|
2704
|
+
* Placeholders used to temporarily replace expressions in the template.
|
|
2705
|
+
*/
|
|
2706
|
+
Component.PLACEHOLDER = (idx) => `__RASTI-${idx}__`;
|
|
2707
|
+
|
|
2708
|
+
/*
|
|
2709
|
+
* Markers used to identify interpolation and recycled components.
|
|
2710
|
+
*/
|
|
2711
|
+
Component.MARKER_RECYCLED = (uid) => `rasti-recycled-${uid}`;
|
|
2712
|
+
Component.MARKER_START = (uid) => `rasti-start-${uid}`;
|
|
2713
|
+
Component.MARKER_END = (uid) => `rasti-end-${uid}`;
|
|
2714
|
+
|
|
2715
|
+
/**
|
|
2716
|
+
* Components are a special kind of `View` that is designed to be easily composable,
|
|
2717
|
+
* making it simple to add child views and build complex user interfaces.
|
|
2718
|
+
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
2719
|
+
* guidelines that allow for a more declarative development style.
|
|
2720
|
+
* 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.
|
|
2721
|
+
* @module
|
|
2722
|
+
* @extends View
|
|
2723
|
+
* @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`.
|
|
2724
|
+
* @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.
|
|
2725
|
+
* @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.
|
|
2726
|
+
* @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.
|
|
2727
|
+
* @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.
|
|
2728
|
+
* @see {@link #module_component_create Component.create}
|
|
2729
|
+
* @example
|
|
2730
|
+
* import { Component, Model } from 'rasti';
|
|
2731
|
+
* // Create Timer component.
|
|
2732
|
+
* const Timer = Component.create`
|
|
2733
|
+
* <div>
|
|
2734
|
+
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
2735
|
+
* </div>
|
|
2736
|
+
* `;
|
|
2737
|
+
* // Create model to store seconds.
|
|
2738
|
+
* const model = new Model({ seconds: 0 });
|
|
2739
|
+
* // Mount timer on body.
|
|
2740
|
+
* Timer.mount({ model }, document.body);
|
|
2741
|
+
* // Increment `model.seconds` every second.
|
|
2742
|
+
* setInterval(() => model.seconds++, 1000);
|
|
2743
|
+
*/
|
|
2744
|
+
var Component$1 = Component.create`<div></div>`;
|
|
1586
2745
|
|
|
1587
|
-
exports.Component = Component;
|
|
2746
|
+
exports.Component = Component$1;
|
|
1588
2747
|
exports.Emitter = Emitter;
|
|
1589
2748
|
exports.Model = Model;
|
|
1590
2749
|
exports.View = View;
|