rasti 3.0.1 → 4.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -11
- package/dist/rasti.js +1767 -622
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +728 -467
- 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 +729 -468
- 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 +725 -466
- 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,987 @@
|
|
|
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.
|
|
805
1718
|
* @return {string} The template with components tags replaced by expressions
|
|
806
1719
|
* placeholders.
|
|
807
1720
|
* @private
|
|
808
1721
|
*/
|
|
809
1722
|
const expandComponents = (main, expressions) => {
|
|
810
|
-
const PH = Component.
|
|
1723
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
811
1724
|
// Match component tags.
|
|
812
1725
|
return main.replace(
|
|
813
1726
|
new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
|
|
814
|
-
|
|
815
|
-
|
|
1727
|
+
(match, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx, selfClosingTag, selfClosingIdx, selfClosingAttrs) => {
|
|
1728
|
+
let tag, close, attributesStr;
|
|
1729
|
+
|
|
1730
|
+
if (openTag) {
|
|
1731
|
+
tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
|
|
1732
|
+
close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
|
|
1733
|
+
attributesStr = nonVoidAttrs;
|
|
1734
|
+
} else {
|
|
1735
|
+
tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
|
|
1736
|
+
attributesStr = selfClosingAttrs;
|
|
1737
|
+
}
|
|
816
1738
|
// No component found.
|
|
817
|
-
if (!(tag.prototype instanceof Component)) return
|
|
1739
|
+
if (!(tag.prototype instanceof Component)) return match;
|
|
818
1740
|
|
|
819
|
-
let
|
|
1741
|
+
let innerList;
|
|
820
1742
|
// Non void component.
|
|
821
1743
|
if (close) {
|
|
822
1744
|
// Close component tag must match open component tag.
|
|
823
|
-
if (tag !== close) return
|
|
1745
|
+
if (tag !== close) return match;
|
|
1746
|
+
// Process inner content same way as partial().
|
|
824
1747
|
// Recursively expand inner components.
|
|
825
|
-
const
|
|
826
|
-
//
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1748
|
+
const innerTemplate = expandComponents(inner, expressions);
|
|
1749
|
+
// Parse partial elements to handle dynamic attributes and events.
|
|
1750
|
+
const parsedInner = parsePartialElements(innerTemplate, expressions);
|
|
1751
|
+
// Split into items.
|
|
1752
|
+
innerList = splitPlaceholders(parsedInner, expressions);
|
|
830
1753
|
}
|
|
1754
|
+
// Parse attributes.
|
|
1755
|
+
const attributes = parseAttributes(attributesStr, expressions);
|
|
831
1756
|
// Create mount function.
|
|
832
1757
|
const mount = function() {
|
|
833
|
-
const options = expandAttributes(attributes, value => getExpressionResult(value, this))
|
|
834
|
-
// Add renderChildren function to options.
|
|
835
|
-
if (
|
|
1758
|
+
const options = expandAttributes(attributes, value => getExpressionResult(value, this));
|
|
1759
|
+
// Add `renderChildren` function to options.
|
|
1760
|
+
if (innerList) {
|
|
1761
|
+
// Evaluate items in parent context and create Partial.
|
|
1762
|
+
options.renderChildren = () => new Partial(innerList.map(item => getExpressionResult(item, this)));
|
|
1763
|
+
}
|
|
836
1764
|
// Mount component.
|
|
837
1765
|
return tag.mount(options);
|
|
838
1766
|
};
|
|
839
1767
|
// Add mount function to expression.
|
|
840
1768
|
expressions.push(mount);
|
|
841
1769
|
// Replace whole string with expression placeholder.
|
|
842
|
-
return Component.
|
|
1770
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
843
1771
|
}
|
|
844
1772
|
);
|
|
845
1773
|
};
|
|
846
1774
|
|
|
847
1775
|
/**
|
|
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.
|
|
1776
|
+
* Replace elements in template.
|
|
1777
|
+
* @param {string} template Template string.
|
|
1778
|
+
* @param {Function} replacer Replacer function.
|
|
1779
|
+
* @return {string} Template string with replaced elements.
|
|
856
1780
|
* @private
|
|
857
1781
|
*/
|
|
858
|
-
const
|
|
859
|
-
const PH = Component.
|
|
1782
|
+
const replaceElements = (template, replacer) => {
|
|
1783
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1784
|
+
return template.replace(
|
|
1785
|
+
new RegExp(`<(${PH}|[a-z]+[1-6]?)(?:\\s*)((?:"[^"]*"|'[^']*'|[^>])*)(/?>)`, 'gi'),
|
|
1786
|
+
replacer
|
|
1787
|
+
);
|
|
1788
|
+
};
|
|
860
1789
|
|
|
861
|
-
|
|
862
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* Parse all HTML elements in template and extract their attributes.
|
|
1792
|
+
* @param {string} template Template string with placeholders.
|
|
1793
|
+
* @param {Array} expressions Array of expressions.
|
|
1794
|
+
* @param {Array} elements Array to store element references.
|
|
1795
|
+
* @return {string} Template with parsed attributes.
|
|
1796
|
+
* @throws {SyntaxError} If the template does not have a single root element or is a container component.
|
|
1797
|
+
* @private
|
|
1798
|
+
*/
|
|
1799
|
+
const parseElements = (template, expressions, elements) => {
|
|
1800
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1801
|
+
// Check if template is a container (single placeholder, no tag).
|
|
1802
|
+
const containerMatch = template.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
1803
|
+
if (containerMatch) return template;
|
|
1804
|
+
// Validate that template has a root element.
|
|
1805
|
+
const rootElementMatch = template.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`));
|
|
1806
|
+
if (!rootElementMatch) throw new SyntaxError(`Template must have a single root element or be a container component: "${template.trim()}"`);
|
|
1807
|
+
|
|
1808
|
+
let elementUid = 0;
|
|
1809
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
1810
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
1811
|
+
const isRoot = elementUid === 0;
|
|
1812
|
+
const currentElementUid = ++elementUid;
|
|
1813
|
+
// If there are no dynamic attributes, return original match.
|
|
1814
|
+
if (!isRoot && !attributesStr.match(new RegExp(PH))) {
|
|
1815
|
+
return match;
|
|
1816
|
+
}
|
|
1817
|
+
// Parse attributes.
|
|
1818
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
1819
|
+
// Create element reference.
|
|
1820
|
+
const generateElementUid = componentUid => `${componentUid}-${currentElementUid}`;
|
|
1821
|
+
// Create function that returns attributes object.
|
|
1822
|
+
const getAttributes = function() {
|
|
1823
|
+
// Expand attributes and events.
|
|
1824
|
+
const attributes = expandEvents(
|
|
1825
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
1826
|
+
this.eventsManager
|
|
1827
|
+
);
|
|
1828
|
+
// Extend template attributes with `options.attributes`.
|
|
1829
|
+
if (isRoot && this.attributes) {
|
|
1830
|
+
Object.assign(attributes, getResult(this.attributes, this));
|
|
1831
|
+
}
|
|
1832
|
+
// Add data attribute for element identification.
|
|
1833
|
+
// First element gets the component uid, others get element uid.
|
|
1834
|
+
attributes[Component.ATTRIBUTE_ELEMENT] = generateElementUid(this.uid);
|
|
1835
|
+
|
|
1836
|
+
return attributes;
|
|
1837
|
+
};
|
|
1838
|
+
|
|
1839
|
+
const getSelector = function() {
|
|
1840
|
+
return `[${Component.ATTRIBUTE_ELEMENT}="${generateElementUid(this.uid)}"]`;
|
|
1841
|
+
};
|
|
1842
|
+
// Add element reference to elements array.
|
|
1843
|
+
elements.push({
|
|
1844
|
+
getSelector,
|
|
1845
|
+
getAttributes,
|
|
1846
|
+
});
|
|
1847
|
+
// Add new expression to expressions array.
|
|
1848
|
+
expressions.push(function() {
|
|
1849
|
+
const attributes = getAttributes.call(this);
|
|
1850
|
+
return Component.markAsSafeHTML(getAttributesHTML(attributes));
|
|
1851
|
+
});
|
|
1852
|
+
// Replace attributes with placeholder.
|
|
1853
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
1854
|
+
// Preserve original tag ending (> or />)
|
|
1855
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
1856
|
+
});
|
|
1857
|
+
};
|
|
863
1858
|
|
|
864
|
-
|
|
1859
|
+
/**
|
|
1860
|
+
* Parse elements in partial template.
|
|
1861
|
+
* @param {string} template Template string with placeholders.
|
|
1862
|
+
* @param {Array} expressions Array of expressions.
|
|
1863
|
+
* @return {string} Template with parsed attributes.
|
|
1864
|
+
* @private
|
|
1865
|
+
*/
|
|
1866
|
+
const parsePartialElements = (template, expressions) => {
|
|
1867
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
1868
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
1869
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
1870
|
+
// If there are no dynamic attributes, return original match.
|
|
1871
|
+
if (!attributesStr.match(new RegExp(PH))) {
|
|
1872
|
+
return match;
|
|
1873
|
+
}
|
|
1874
|
+
// Parse attributes.
|
|
1875
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
1876
|
+
// Create function that returns attributes object.
|
|
1877
|
+
const getAttributes = function() {
|
|
1878
|
+
const attributes = expandEvents(
|
|
1879
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
1880
|
+
this.eventsManager
|
|
1881
|
+
);
|
|
1882
|
+
|
|
1883
|
+
return attributes;
|
|
1884
|
+
};
|
|
1885
|
+
// Add new expression to expressions array.
|
|
1886
|
+
expressions.push(function() {
|
|
1887
|
+
const attributes = getAttributes.call(this);
|
|
1888
|
+
return Component.markAsSafeHTML(getAttributesHTML(attributes));
|
|
1889
|
+
});
|
|
1890
|
+
// Replace attributes with placeholder.
|
|
1891
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
1892
|
+
// Preserve original tag ending (> or />)
|
|
1893
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
1894
|
+
});
|
|
1895
|
+
};
|
|
865
1896
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
1897
|
+
/**
|
|
1898
|
+
* Parse all interpolations in template text content.
|
|
1899
|
+
* @param {string} template Template string with placeholders.
|
|
1900
|
+
* @param {Array} expressions Array of expressions.
|
|
1901
|
+
* @param {Array} interpolations Array to store interpolation references.
|
|
1902
|
+
* @return {string} Template with interpolation markers.
|
|
1903
|
+
* @private
|
|
1904
|
+
*/
|
|
1905
|
+
const parseInterpolations = (template, expressions, interpolations) => {
|
|
1906
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1907
|
+
let interpolationUid = 0;
|
|
1908
|
+
// Match all expression placeholders.
|
|
1909
|
+
return template.replace(
|
|
1910
|
+
new RegExp(PH, 'g'),
|
|
1911
|
+
function(match, expressionIndex, offset) {
|
|
1912
|
+
// Check if this placeholder is inside an element tag (attribute).
|
|
1913
|
+
// `offset` is the index of the match in the original string.
|
|
1914
|
+
const beforeMatch = template.substring(0, offset);
|
|
1915
|
+
const lastOpenTag = beforeMatch.lastIndexOf('<');
|
|
1916
|
+
const lastCloseTag = beforeMatch.lastIndexOf('>');
|
|
1917
|
+
// If we're inside an element tag, don't process as interpolation.
|
|
1918
|
+
if (lastOpenTag > lastCloseTag) {
|
|
1919
|
+
return match;
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const currentInterpolationUid = ++interpolationUid;
|
|
1923
|
+
|
|
1924
|
+
function getStart() {
|
|
1925
|
+
return Component.MARKER_START(`${this.uid}-${currentInterpolationUid}`);
|
|
1926
|
+
}
|
|
1927
|
+
function getEnd() {
|
|
1928
|
+
return Component.MARKER_END(`${this.uid}-${currentInterpolationUid}`);
|
|
1929
|
+
}
|
|
1930
|
+
// Add interpolation reference to interpolations array.
|
|
1931
|
+
interpolations.push({
|
|
1932
|
+
getStart,
|
|
1933
|
+
getEnd,
|
|
1934
|
+
expression : expressions[expressionIndex]
|
|
1935
|
+
});
|
|
1936
|
+
// Add new expression to expressions array.
|
|
1937
|
+
expressions.push(function() {
|
|
1938
|
+
const result = getExpressionResult(expressions[expressionIndex], this);
|
|
1939
|
+
const uid = `${this.uid}-${currentInterpolationUid}`;
|
|
1940
|
+
return new InterpolationWrapper(uid, result);
|
|
1941
|
+
});
|
|
1942
|
+
// Replace with new placeholder.
|
|
1943
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
1944
|
+
}
|
|
1945
|
+
);
|
|
1946
|
+
};
|
|
1947
|
+
|
|
1948
|
+
/**
|
|
1949
|
+
* Parse attributes string to extract dynamic attributes.
|
|
1950
|
+
* @param {string} attributesStr Attributes string from HTML element.
|
|
1951
|
+
* @param {Array} expressions Array of expressions.
|
|
1952
|
+
* @return {Array} Array of attribute pairs [key, value] or [key, value, hasQuotes].
|
|
1953
|
+
* @private
|
|
1954
|
+
*/
|
|
1955
|
+
const parseAttributes = (attributesStr, expressions) => {
|
|
1956
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
1957
|
+
const attributes = [];
|
|
1958
|
+
// Parse attributes string with support for placeholders in both names and values.
|
|
879
1959
|
const regExp = new RegExp(`(${PH}|[\\w-]+)(?:=(["']?)(?:${PH}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`, 'g');
|
|
880
1960
|
|
|
881
1961
|
let attributeMatch;
|
|
882
1962
|
while ((attributeMatch = regExp.exec(attributesStr)) !== null) {
|
|
883
|
-
const [, attribute, attributeIdx
|
|
1963
|
+
const [, attribute, attributeIdx, quotes, valueIdx, value] = attributeMatch;
|
|
884
1964
|
|
|
885
|
-
const attr = typeof attributeIdx !== 'undefined' ? expressions[attributeIdx] : attribute;
|
|
886
|
-
const val = typeof valueIdx !== 'undefined' ? expressions[valueIdx] : value;
|
|
1965
|
+
const attr = typeof attributeIdx !== 'undefined' ? expressions[parseInt(attributeIdx, 10)] : attribute;
|
|
1966
|
+
const val = typeof valueIdx !== 'undefined' ? expressions[parseInt(valueIdx, 10)] : value;
|
|
887
1967
|
|
|
888
1968
|
if (typeof val !== 'undefined') {
|
|
889
|
-
|
|
1969
|
+
attributes.push([attr, val, !!quotes]);
|
|
890
1970
|
} else {
|
|
891
|
-
|
|
1971
|
+
attributes.push([attr]);
|
|
892
1972
|
}
|
|
893
1973
|
}
|
|
894
1974
|
|
|
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
|
|
1975
|
+
return attributes;
|
|
904
1976
|
};
|
|
905
1977
|
|
|
906
1978
|
/*
|
|
907
1979
|
* These option keys will be extended on the component instance.
|
|
908
1980
|
*/
|
|
909
|
-
const componentOptions = ['key', 'state', 'onCreate', 'onChange', '
|
|
1981
|
+
const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onHydrate', 'onRecycle', 'onUpdate'];
|
|
910
1982
|
|
|
911
1983
|
/**
|
|
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);
|
|
1984
|
+
* @lends module:Component
|
|
938
1985
|
*/
|
|
939
1986
|
class Component extends View {
|
|
940
1987
|
constructor(options = {}) {
|
|
941
1988
|
super(...arguments);
|
|
1989
|
+
this.componentOptions = [];
|
|
942
1990
|
// Extend "this" with options.
|
|
943
1991
|
componentOptions.forEach(key => {
|
|
944
|
-
if (key in options)
|
|
1992
|
+
if (key in options) {
|
|
1993
|
+
this[key] = options[key];
|
|
1994
|
+
this.componentOptions.push(key);
|
|
1995
|
+
}
|
|
945
1996
|
});
|
|
1997
|
+
// Extract props from options that aren't component or view options.
|
|
1998
|
+
const props = {};
|
|
1999
|
+
Object.keys(options).forEach(key => {
|
|
2000
|
+
if (!this.viewOptions.includes(key) && !this.componentOptions.includes(key)) {
|
|
2001
|
+
props[key] = options[key];
|
|
2002
|
+
}
|
|
2003
|
+
});
|
|
2004
|
+
// Store props as Model for reactive updates.
|
|
2005
|
+
this.props = new Model(props);
|
|
946
2006
|
// Store options by default.
|
|
947
2007
|
this.options = options;
|
|
948
2008
|
// Bind `partial` method to `this`.
|
|
949
2009
|
this.partial = this.partial.bind(this);
|
|
2010
|
+
// Bind `onChange` method to `this`.
|
|
2011
|
+
this.onChange = this.onChange.bind(this);
|
|
950
2012
|
// Call lifecycle method.
|
|
951
2013
|
this.onCreate.apply(this, arguments);
|
|
952
2014
|
}
|
|
953
2015
|
|
|
954
2016
|
/**
|
|
955
|
-
*
|
|
956
|
-
*
|
|
957
|
-
*
|
|
958
|
-
* @param {Rasti.Model} model A model or emitter object to listen to changes.
|
|
959
|
-
* @return {Rasti.Component} The component instance.
|
|
2017
|
+
* Get events object for automatic event delegation, based on data attributes.
|
|
2018
|
+
* @return {object} The events object.
|
|
2019
|
+
* @private
|
|
960
2020
|
*/
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
2021
|
+
events() {
|
|
2022
|
+
const events = {};
|
|
2023
|
+
// Create events object.
|
|
2024
|
+
this.eventsManager.types.forEach(type => {
|
|
2025
|
+
const dataAttribute = Component.ATTRIBUTE_EVENT(type);
|
|
2026
|
+
// Create a listener function that gets the listener index from the data attribute and calls the listener.
|
|
2027
|
+
const listener = function(event, component, matched) {
|
|
2028
|
+
// Get the listener index from the data attribute.
|
|
2029
|
+
const index = matched.getAttribute(dataAttribute);
|
|
2030
|
+
// Root element listener may not have a data attribute.
|
|
2031
|
+
if (index) {
|
|
2032
|
+
let currentListener = this.eventsManager.listeners[parseInt(index, 10)];
|
|
2033
|
+
if (typeof currentListener === 'string') currentListener = this[currentListener];
|
|
2034
|
+
validateListener(currentListener);
|
|
2035
|
+
// Call the listener.
|
|
2036
|
+
currentListener.call(this, event, component, matched);
|
|
2037
|
+
}
|
|
2038
|
+
};
|
|
2039
|
+
// Add an event listener to the events object for each event type, using the data attribute
|
|
2040
|
+
// as both a CSS selector and to store the listener's index.
|
|
2041
|
+
events[`${type} [${dataAttribute}]`] = listener;
|
|
2042
|
+
// Add an event listener to the events object for each event type that matches the root element.
|
|
2043
|
+
events[type] = listener;
|
|
2044
|
+
});
|
|
2045
|
+
|
|
2046
|
+
return events;
|
|
2047
|
+
}
|
|
975
2048
|
|
|
2049
|
+
/**
|
|
2050
|
+
* Subscribes to a `change` event on a model or emitter object and invokes the `onChange` lifecycle method.
|
|
2051
|
+
* The subscription is automatically cleaned up when the component is destroyed.
|
|
2052
|
+
* By default, the component subscribes to changes on `this.model`, `this.state`, and `this.props`.
|
|
2053
|
+
*
|
|
2054
|
+
* @param {Object} model - The model or emitter object to listen to.
|
|
2055
|
+
* @param {string} [type='change'] - The event type to listen for.
|
|
2056
|
+
* @param {Function} [listener=this.onChange] - The callback to invoke when the event is emitted.
|
|
2057
|
+
* @returns {Component} The current component instance for chaining.
|
|
2058
|
+
*/
|
|
2059
|
+
subscribe(model, type = 'change', listener = this.onChange) {
|
|
2060
|
+
// Check if model has `on` method.
|
|
2061
|
+
if (model.on) this.listenTo(model, type, listener);
|
|
976
2062
|
return this;
|
|
977
2063
|
}
|
|
978
2064
|
|
|
@@ -985,7 +2071,7 @@
|
|
|
985
2071
|
* @private
|
|
986
2072
|
*/
|
|
987
2073
|
isContainer() {
|
|
988
|
-
return
|
|
2074
|
+
return this.template.elements.length === 0 && this.template.interpolations.length === 1;
|
|
989
2075
|
}
|
|
990
2076
|
|
|
991
2077
|
/**
|
|
@@ -994,134 +2080,107 @@
|
|
|
994
2080
|
* @private
|
|
995
2081
|
*/
|
|
996
2082
|
ensureElement() {
|
|
2083
|
+
// Store data event listeners.
|
|
2084
|
+
this.eventsManager = new EventsManager();
|
|
2085
|
+
// Store position tracking for recycling.
|
|
2086
|
+
this.pathManager = new PathManager();
|
|
2087
|
+
// Call template function.
|
|
2088
|
+
this.template = getResult(this.template, this);
|
|
997
2089
|
// If el is provided, delegate events.
|
|
998
2090
|
if (this.el) {
|
|
999
2091
|
// If "this.el" is a function, call it to get the element.
|
|
1000
2092
|
this.el = getResult(this.el, this);
|
|
1001
|
-
|
|
2093
|
+
// Render the component as a string to generate children components.
|
|
2094
|
+
this.toString();
|
|
2095
|
+
// Hydrate the component.
|
|
2096
|
+
this.hydrate(this.el.parentNode);
|
|
1002
2097
|
}
|
|
1003
2098
|
}
|
|
1004
2099
|
|
|
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
2100
|
/**
|
|
1063
2101
|
* Used internally on the render process.
|
|
1064
2102
|
* Attach the `Component` to the dom element providing `this.el`, delegate events,
|
|
1065
|
-
* subscribe to model changes and call `
|
|
2103
|
+
* subscribe to model changes and call `onHydrate` lifecycle method.
|
|
1066
2104
|
* @param parent {node} The parent node.
|
|
1067
|
-
* @return {
|
|
2105
|
+
* @return {Component} The component instance.
|
|
1068
2106
|
* @private
|
|
1069
2107
|
*/
|
|
1070
2108
|
hydrate(parent) {
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
if (this.state) this.subscribe(this.state);
|
|
2109
|
+
['model', 'state', 'props'].forEach(key => {
|
|
2110
|
+
if (this[key]) this.subscribe(this[key]);
|
|
2111
|
+
});
|
|
1075
2112
|
|
|
1076
2113
|
if (this.isContainer()) {
|
|
2114
|
+
// Get references for interpolation marker comments
|
|
2115
|
+
this.template.interpolations[0].hydrate(parent);
|
|
2116
|
+
// Call hydrate on children.
|
|
1077
2117
|
this.children[0].hydrate(parent);
|
|
2118
|
+
// Set the first element as the component's element.
|
|
1078
2119
|
this.el = this.children[0].el;
|
|
1079
2120
|
} else {
|
|
1080
|
-
|
|
2121
|
+
// Search for every element in template using getSelector
|
|
2122
|
+
this.template.elements.forEach((element, index) => {
|
|
2123
|
+
if (index === 0) {
|
|
2124
|
+
element.hydrate(parent);
|
|
2125
|
+
if (this.el) element.ref = this.el;
|
|
2126
|
+
else this.el = element.ref;
|
|
2127
|
+
}
|
|
2128
|
+
else {
|
|
2129
|
+
element.hydrate(this.el);
|
|
2130
|
+
}
|
|
2131
|
+
});
|
|
2132
|
+
// Delegate events.
|
|
1081
2133
|
this.delegateEvents();
|
|
2134
|
+
// Get references for interpolation marker comments
|
|
2135
|
+
this.template.interpolations.forEach(interpolation => interpolation.hydrate(this.el));
|
|
1082
2136
|
this.children.forEach(child => child.hydrate(this.el));
|
|
1083
2137
|
}
|
|
1084
|
-
// Call `
|
|
1085
|
-
this.
|
|
2138
|
+
// Call `onHydrate` lifecycle method.
|
|
2139
|
+
this.onHydrate.call(this);
|
|
1086
2140
|
// Return `this` for chaining.
|
|
1087
2141
|
return this;
|
|
1088
2142
|
}
|
|
1089
2143
|
|
|
1090
2144
|
/**
|
|
1091
|
-
*
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
* @param parent {node} The parent node.
|
|
1095
|
-
* @return {Rasti.Component} The component instance.
|
|
2145
|
+
* Get a `comment` marker with same data attribute as this component.
|
|
2146
|
+
* Used to replace the component when it is recycled.
|
|
2147
|
+
* @return {string} The recycle placeholder.
|
|
1096
2148
|
* @private
|
|
1097
2149
|
*/
|
|
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;
|
|
2150
|
+
getRecycledMarker() {
|
|
2151
|
+
return `<!--${Component.MARKER_RECYCLED(this.uid)}-->`;
|
|
1112
2152
|
}
|
|
1113
2153
|
|
|
1114
2154
|
/**
|
|
1115
|
-
*
|
|
1116
|
-
*
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
2155
|
+
* Get the component nodes to be inserted into the DOM.
|
|
2156
|
+
* Used internally during the render process, you usually don't need to call it if you use
|
|
2157
|
+
* `mount()`.
|
|
2158
|
+
* For components that render HTML elements you can safely rely on `this.el`.
|
|
2159
|
+
* For container components that render another component you need the wrapper nodes to insert
|
|
2160
|
+
* them into the DOM; use `getNodes()` for that.
|
|
2161
|
+
* @return {Node[]} The component nodes.
|
|
1119
2162
|
*/
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
2163
|
+
getNodes() {
|
|
2164
|
+
return this.isContainer() ?
|
|
2165
|
+
[this.template.interpolations[0].ref[0], ...this.children[0].getNodes(), this.template.interpolations[0].ref[1]] :
|
|
2166
|
+
[this.el];
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
/**
|
|
2170
|
+
* Used internally on the render process.
|
|
2171
|
+
* Reuse a `Component` by replacing the placeholder comment with the real nodes.
|
|
2172
|
+
* Call `onRecycle` lifecycle method.
|
|
2173
|
+
* @param parent {node} The parent node.
|
|
2174
|
+
* @return {Component} The component instance.
|
|
2175
|
+
* @private
|
|
2176
|
+
*/
|
|
2177
|
+
recycle(parent) {
|
|
2178
|
+
// Locate the placeholder comment and replace it with the real nodes
|
|
2179
|
+
const toBeReplaced = findComment(parent, Component.MARKER_RECYCLED(this.uid), isComponent);
|
|
2180
|
+
// Replace it with this.el.
|
|
2181
|
+
toBeReplaced.replaceWith(...this.getNodes());
|
|
2182
|
+
// Call `onRecycle` lifecycle method.
|
|
2183
|
+
this.onRecycle.call(this);
|
|
1125
2184
|
// Return `this` for chaining.
|
|
1126
2185
|
return this;
|
|
1127
2186
|
}
|
|
@@ -1147,16 +2206,22 @@
|
|
|
1147
2206
|
}
|
|
1148
2207
|
|
|
1149
2208
|
/**
|
|
1150
|
-
* Lifecycle method. Called
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
2209
|
+
* Lifecycle method. Called when the component is rendered for the first time and hydrated.
|
|
2210
|
+
*/
|
|
2211
|
+
onHydrate() {}
|
|
2212
|
+
|
|
2213
|
+
/**
|
|
2214
|
+
* Lifecycle method. Called when the component is recycled (reused with the same key) and added to the DOM again.
|
|
2215
|
+
*/
|
|
2216
|
+
onRecycle() {}
|
|
2217
|
+
|
|
2218
|
+
/**
|
|
2219
|
+
* Lifecycle method. Called when the component is updated or re-rendered.
|
|
1155
2220
|
*/
|
|
1156
|
-
|
|
2221
|
+
onUpdate() {}
|
|
1157
2222
|
|
|
1158
2223
|
/**
|
|
1159
|
-
* Lifecycle method. Called when the
|
|
2224
|
+
* Lifecycle method. Called when the component is destroyed.
|
|
1160
2225
|
* @param {object} options Options object or any arguments passed to `destroy` method.
|
|
1161
2226
|
*/
|
|
1162
2227
|
onDestroy() {}
|
|
@@ -1164,18 +2229,18 @@
|
|
|
1164
2229
|
/**
|
|
1165
2230
|
* Tagged template helper method.
|
|
1166
2231
|
* Used to create a partial template.
|
|
1167
|
-
* It will return a
|
|
2232
|
+
* It will return a Partial object that preserves structure for position-based recycling.
|
|
1168
2233
|
* Components will be added as children by the parent component. Template strings literals
|
|
1169
2234
|
* will be marked as safe HTML to be rendered.
|
|
1170
2235
|
* This method is bound to the component instance by default.
|
|
1171
2236
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
1172
2237
|
* @param {...any} expressions - Template expressions.
|
|
1173
|
-
* @return {
|
|
2238
|
+
* @return {Partial} Partial object containing strings and expressions.
|
|
1174
2239
|
* @example
|
|
1175
2240
|
* import { Component } from 'rasti';
|
|
1176
2241
|
* // Create a Title component.
|
|
1177
2242
|
* const Title = Component.create`
|
|
1178
|
-
* <h1>${
|
|
2243
|
+
* <h1>${({ props }) => props.children}</h1>
|
|
1179
2244
|
* `;
|
|
1180
2245
|
* // Create Main component.
|
|
1181
2246
|
* const Main = Component.create`
|
|
@@ -1195,147 +2260,191 @@
|
|
|
1195
2260
|
* });
|
|
1196
2261
|
*/
|
|
1197
2262
|
partial(strings, ...expressions) {
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
expandComponents(
|
|
1201
|
-
|
|
1202
|
-
|
|
2263
|
+
const items = splitPlaceholders(
|
|
2264
|
+
parsePartialElements(
|
|
2265
|
+
expandComponents(
|
|
2266
|
+
addPlaceholders(strings, expressions),
|
|
2267
|
+
expressions
|
|
2268
|
+
),
|
|
2269
|
+
expressions
|
|
2270
|
+
),
|
|
2271
|
+
expressions
|
|
2272
|
+
).map(item => getExpressionResult(item, this));
|
|
2273
|
+
|
|
2274
|
+
return new Partial(items);
|
|
1203
2275
|
}
|
|
1204
2276
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
2277
|
+
/**
|
|
2278
|
+
* Render a template part.
|
|
2279
|
+
* @param {any} part - The template part.
|
|
2280
|
+
* @param {function} addChild - The addChild function.
|
|
2281
|
+
* @return {string} The rendered template part.
|
|
2282
|
+
* @private
|
|
2283
|
+
*/
|
|
2284
|
+
renderTemplatePart(part, addChild) {
|
|
2285
|
+
const result = getExpressionResult(part, this);
|
|
2286
|
+
|
|
2287
|
+
const parse = item => {
|
|
2288
|
+
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
2289
|
+
if (item instanceof SafeHTML) return item;
|
|
2290
|
+
if (item instanceof Component) return addChild(item);
|
|
2291
|
+
|
|
2292
|
+
if (item instanceof Partial) {
|
|
2293
|
+
this.pathManager.push();
|
|
2294
|
+
const out = item.items.map(subItem => { this.pathManager.increment(); return parse(subItem); }).join('');
|
|
2295
|
+
this.pathManager.pop();
|
|
2296
|
+
return out;
|
|
2297
|
+
}
|
|
2298
|
+
// Handle arrays (user loops) - disable tracking.
|
|
2299
|
+
if (Array.isArray(item)) {
|
|
2300
|
+
this.pathManager.pause();
|
|
2301
|
+
const out = deepFlat(item).map(parse).join('');
|
|
2302
|
+
this.pathManager.resume();
|
|
2303
|
+
return out;
|
|
2304
|
+
}
|
|
2305
|
+
// InterpolationWrapper: add markers and process maintaining tracking.
|
|
2306
|
+
if (item instanceof InterpolationWrapper) {
|
|
2307
|
+
this.pathManager.increment();
|
|
2308
|
+
const startMarker = `<!--${Component.MARKER_START(item.interpolationUid)}-->`;
|
|
2309
|
+
const endMarker = `<!--${Component.MARKER_END(item.interpolationUid)}-->`;
|
|
2310
|
+
return `${startMarker}${parse(item.result)}${endMarker}`;
|
|
2311
|
+
}
|
|
1210
2312
|
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
2313
|
+
return Component.sanitize(item);
|
|
2314
|
+
}
|
|
2315
|
+
// Return empty string if item is undefined, null, false, or true.
|
|
2316
|
+
return '';
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2319
|
+
return `${parse(result)}`;
|
|
1214
2320
|
}
|
|
1215
2321
|
|
|
1216
|
-
|
|
1217
|
-
*
|
|
2322
|
+
/**
|
|
2323
|
+
* Render the component as a string.
|
|
2324
|
+
* Used internally on the render process.
|
|
2325
|
+
* Use it for server-side rendering or static site generation.
|
|
2326
|
+
* @return {string} The rendered component.
|
|
1218
2327
|
*/
|
|
1219
2328
|
toString() {
|
|
1220
2329
|
// Normally there won't be any children, but if there are, destroy them.
|
|
1221
2330
|
this.destroyChildren();
|
|
1222
|
-
//
|
|
1223
|
-
|
|
1224
|
-
//
|
|
1225
|
-
|
|
1226
|
-
//
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
2331
|
+
// Normally there won't be any data event listeners, but if there are, clear them.
|
|
2332
|
+
this.eventsManager.reset();
|
|
2333
|
+
// Reset position tracking.
|
|
2334
|
+
this.pathManager.reset();
|
|
2335
|
+
// Bind addChild method.
|
|
2336
|
+
const addChild = component => {
|
|
2337
|
+
this.pathManager.track(component);
|
|
2338
|
+
return this.addChild(component);
|
|
2339
|
+
};
|
|
2340
|
+
// Render the template parts.
|
|
2341
|
+
return this.template.parts
|
|
2342
|
+
.map(part => this.renderTemplatePart(part, addChild))
|
|
2343
|
+
.join('');
|
|
1234
2344
|
}
|
|
1235
2345
|
|
|
1236
2346
|
/**
|
|
1237
2347
|
* 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,
|
|
2348
|
+
* - If `this.el` is not present, the `Component` will be rendered as a string inside a `DocumentFragment` and hydrated, making `this.el` available. The `onHydrate` lifecycle method will be called.
|
|
2349
|
+
* - If `this.el` is present, the method will update the attributes and inner HTML of the element, or recreate its child component in the case of a container. The `onUpdate` lifecycle method will be called.
|
|
2350
|
+
* - When rendering child components, recycling happens in two ways:
|
|
2351
|
+
* - Components with a `key` are recycled if a previous child with the same key exists.
|
|
2352
|
+
* - Unkeyed components are recycled if they have the same type and position in the template or partial.
|
|
2353
|
+
* A recycled `Component` will call the `onRecycle` lifecycle method.
|
|
1241
2354
|
* - If the active element is inside the component, it will retain focus after the render.
|
|
1242
|
-
* @return {
|
|
2355
|
+
* @return {Component} The component instance.
|
|
1243
2356
|
*/
|
|
1244
2357
|
render() {
|
|
1245
2358
|
// Prevent a last re render if view is already destroyed.
|
|
1246
2359
|
if (this.destroyed) return this;
|
|
1247
2360
|
// If `this.el` is not present, render the view as a string and hydrate it.
|
|
1248
2361
|
if (!this.el) {
|
|
1249
|
-
const fragment = this
|
|
1250
|
-
fragment
|
|
1251
|
-
this.hydrate(fragment.content);
|
|
2362
|
+
const fragment = parseHTML(this);
|
|
2363
|
+
this.hydrate(fragment);
|
|
1252
2364
|
return this;
|
|
1253
2365
|
}
|
|
1254
|
-
//
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
//
|
|
1267
|
-
|
|
1268
|
-
// Store active element.
|
|
1269
|
-
const activeElement = document.activeElement;
|
|
1270
|
-
|
|
2366
|
+
// Clear event listeners.
|
|
2367
|
+
this.eventsManager.reset();
|
|
2368
|
+
// Reset position tracking.
|
|
2369
|
+
this.pathManager.reset();
|
|
2370
|
+
// Store active element.
|
|
2371
|
+
const activeElement = this.isContainer() ? null : document.activeElement;
|
|
2372
|
+
// Update elements.
|
|
2373
|
+
this.template.elements.forEach(element => element.update());
|
|
2374
|
+
// Store previous children.
|
|
2375
|
+
const previousChildren = this.children;
|
|
2376
|
+
// Clear current children.
|
|
2377
|
+
this.children = [];
|
|
2378
|
+
// Update interpolations.
|
|
2379
|
+
this.template.interpolations.forEach(interpolation => {
|
|
1271
2380
|
const nextChildren = [];
|
|
1272
2381
|
const recycledChildren = [];
|
|
1273
2382
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
const inner = this.template.call(this, component => {
|
|
2383
|
+
this.pathManager.increment();
|
|
2384
|
+
|
|
2385
|
+
const addChild = component => {
|
|
1278
2386
|
let out = component;
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
2387
|
+
let found = null;
|
|
2388
|
+
// Check if child already exists by key.
|
|
2389
|
+
if (component.key) {
|
|
2390
|
+
found = previousChildren.find(prev => prev.key === component.key);
|
|
2391
|
+
} else {
|
|
2392
|
+
// Find by position and type.
|
|
2393
|
+
found = this.pathManager.findRecyclable(component.constructor);
|
|
2394
|
+
}
|
|
1283
2395
|
|
|
1284
2396
|
if (found) {
|
|
1285
2397
|
// If child already exists, replace it html by its root element.
|
|
1286
|
-
out = found.
|
|
2398
|
+
out = found.getRecycledMarker();
|
|
1287
2399
|
// Add child to recycled children.
|
|
1288
|
-
recycledChildren.push(found);
|
|
1289
|
-
//
|
|
1290
|
-
|
|
2400
|
+
recycledChildren.push([found, component]);
|
|
2401
|
+
// Track the component.
|
|
2402
|
+
if (!found.key) this.pathManager.track(found);
|
|
1291
2403
|
} else {
|
|
1292
|
-
//
|
|
2404
|
+
// Add new component.
|
|
1293
2405
|
nextChildren.push(component);
|
|
2406
|
+
// Track the component.
|
|
2407
|
+
this.pathManager.track(component);
|
|
1294
2408
|
}
|
|
1295
|
-
//
|
|
2409
|
+
// Return the component or placeholder.
|
|
1296
2410
|
return out;
|
|
1297
|
-
}
|
|
2411
|
+
};
|
|
1298
2412
|
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
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();
|
|
2413
|
+
const fragment = parseHTML(this.renderTemplatePart(interpolation.expression, addChild));
|
|
2414
|
+
// Replace children root elements with recycled components.
|
|
2415
|
+
recycledChildren.forEach(([recycled, discarded]) => {
|
|
2416
|
+
this.addChild(recycled).recycle(fragment);
|
|
2417
|
+
// Update props.
|
|
2418
|
+
recycled.props.set(discarded.props.toJSON());
|
|
2419
|
+
// Destroy discarded component.
|
|
2420
|
+
discarded.destroy();
|
|
2421
|
+
});
|
|
2422
|
+
// Add new children. Hydrate them.
|
|
2423
|
+
nextChildren.forEach(child => {
|
|
2424
|
+
this.addChild(child).hydrate(fragment);
|
|
1331
2425
|
});
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
2426
|
+
|
|
2427
|
+
interpolation.update(fragment);
|
|
2428
|
+
});
|
|
2429
|
+
// Destroy unused children.
|
|
2430
|
+
previousChildren.forEach(prev => {
|
|
2431
|
+
if (this.children.indexOf(prev) < 0) prev.destroy();
|
|
2432
|
+
});
|
|
2433
|
+
// If container, set el to the child element.
|
|
2434
|
+
if (this.isContainer()) {
|
|
2435
|
+
this.el = this.children[0].el;
|
|
2436
|
+
} else {
|
|
2437
|
+
// If there are pending event types, delegate events again.
|
|
2438
|
+
if (this.eventsManager.hasPendingTypes()) {
|
|
2439
|
+
this.delegateEvents();
|
|
1335
2440
|
}
|
|
1336
2441
|
}
|
|
1337
|
-
//
|
|
1338
|
-
this.
|
|
2442
|
+
// Restore focus.
|
|
2443
|
+
if (activeElement && this.el.contains(activeElement)) {
|
|
2444
|
+
activeElement.focus();
|
|
2445
|
+
}
|
|
2446
|
+
// Call onUpdate lifecycle method.
|
|
2447
|
+
this.onUpdate.call(this);
|
|
1339
2448
|
// Return this for chaining.
|
|
1340
2449
|
return this;
|
|
1341
2450
|
}
|
|
@@ -1348,7 +2457,7 @@
|
|
|
1348
2457
|
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
1349
2458
|
* @static
|
|
1350
2459
|
* @param {string} value
|
|
1351
|
-
* @return {
|
|
2460
|
+
* @return {SafeHTML} A safe HTML object.
|
|
1352
2461
|
*/
|
|
1353
2462
|
static markAsSafeHTML(value) {
|
|
1354
2463
|
return new SafeHTML(value);
|
|
@@ -1357,7 +2466,7 @@
|
|
|
1357
2466
|
/**
|
|
1358
2467
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
1359
2468
|
* @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.
|
|
2469
|
+
* @param {object|Function} object Object containing methods to be added to the new `Component` subclass. Also can be a function that receives the parent prototype and returns an object.
|
|
1361
2470
|
*/
|
|
1362
2471
|
static extend(object) {
|
|
1363
2472
|
const Current = this;
|
|
@@ -1378,27 +2487,28 @@
|
|
|
1378
2487
|
* appends its element into the DOM (if `el` is provided).
|
|
1379
2488
|
* And returns the view instance.
|
|
1380
2489
|
* @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 {
|
|
2490
|
+
* @param {object} [options] The view options.
|
|
2491
|
+
* @param {node} [el] Dom element to append the view element.
|
|
2492
|
+
* @param {boolean} [hydrate] If true, the view will hydrate existing DOM.
|
|
2493
|
+
* @return {Component} The component instance.
|
|
1385
2494
|
*/
|
|
1386
2495
|
static mount(options, el, hydrate) {
|
|
1387
|
-
// Instantiate
|
|
1388
|
-
const
|
|
2496
|
+
// Instantiate component.
|
|
2497
|
+
const component = new this(options);
|
|
1389
2498
|
// If `el` is passed, mount component.
|
|
1390
2499
|
if (el) {
|
|
1391
2500
|
if (hydrate) {
|
|
2501
|
+
// Generate subcomponents.
|
|
2502
|
+
component.toString();
|
|
1392
2503
|
// Hydrate existing DOM.
|
|
1393
|
-
|
|
1394
|
-
view.hydrate(el);
|
|
2504
|
+
component.hydrate(el);
|
|
1395
2505
|
} else {
|
|
1396
2506
|
// Append element to the DOM.
|
|
1397
|
-
el.
|
|
2507
|
+
el.append(...component.render().getNodes());
|
|
1398
2508
|
}
|
|
1399
2509
|
}
|
|
1400
|
-
// Return
|
|
1401
|
-
return
|
|
2510
|
+
// Return component instance.
|
|
2511
|
+
return component;
|
|
1402
2512
|
}
|
|
1403
2513
|
|
|
1404
2514
|
/**
|
|
@@ -1411,16 +2521,45 @@
|
|
|
1411
2521
|
* - Template interpolations that are functions will be evaluated during the render process, receiving the view instance as an argument and being bound to it. If the function returns `null`, `undefined`, `false`, or an empty string, the interpolation won't render any content.
|
|
1412
2522
|
* ```javascript
|
|
1413
2523
|
* const Button = Component.create`
|
|
1414
|
-
* <button class="${({
|
|
1415
|
-
* ${({
|
|
2524
|
+
* <button class="${({ props }) => props.className}">
|
|
2525
|
+
* ${({ props }) => props.children}
|
|
2526
|
+
* </button>
|
|
2527
|
+
* `;
|
|
2528
|
+
* ```
|
|
2529
|
+
* - Attach DOM event handlers per element using camel-cased attributes.
|
|
2530
|
+
* Event handlers are automatically bound to the component instance (`this`).
|
|
2531
|
+
* Internally, Rasti uses event delegation to the component's root element for performance.
|
|
2532
|
+
*
|
|
2533
|
+
* **Attribute Quoting:**
|
|
2534
|
+
* - **Quoted attributes** (`onClick="${handler}"`) evaluate the expression first, useful for dynamic values
|
|
2535
|
+
* - **Unquoted attributes** (`onClick=${handler}`) pass the function reference directly
|
|
2536
|
+
*
|
|
2537
|
+
* **Listener Signature:** `(event, component, matched)`
|
|
2538
|
+
* - `event`: The native DOM event object
|
|
2539
|
+
* - `component`: The component instance (same as `this`)
|
|
2540
|
+
* - `matched`: The element that matched the event (useful for delegation)
|
|
2541
|
+
*
|
|
2542
|
+
* ```javascript
|
|
2543
|
+
* const Button = Component.create`
|
|
2544
|
+
* <button
|
|
2545
|
+
* onClick=${function(event, component, matched) {
|
|
2546
|
+
* // this === component
|
|
2547
|
+
* console.log('Button clicked:', matched);
|
|
2548
|
+
* }}
|
|
2549
|
+
* onMouseOver="${({ model }) => () => model.isHovered = true}"
|
|
2550
|
+
* onMouseOut="${({ model }) => () => model.isHovered = false}"
|
|
2551
|
+
* >
|
|
2552
|
+
* Click me
|
|
1416
2553
|
* </button>
|
|
1417
2554
|
* `;
|
|
1418
2555
|
* ```
|
|
1419
|
-
*
|
|
2556
|
+
*
|
|
2557
|
+
* If you need custom delegation (e.g., `{'click .selector': 'handler'}`),
|
|
2558
|
+
* you may override the `events` property as described in {@link #module_view__delegateevents View.delegateEvents}.
|
|
1420
2559
|
* - Boolean attributes should be passed in the format `attribute="${() => true}"`. `false` attributes won't be rendered. `true` attributes will be rendered without a value.
|
|
1421
2560
|
* ```javascript
|
|
1422
2561
|
* const Input = Component.create`
|
|
1423
|
-
* <input type="text" disabled=${({
|
|
2562
|
+
* <input type="text" disabled=${({ props }) => props.disabled} />
|
|
1424
2563
|
* `;
|
|
1425
2564
|
* ```
|
|
1426
2565
|
* - If the interpolated function returns a component instance, it will be added as a child component.
|
|
@@ -1429,21 +2568,21 @@
|
|
|
1429
2568
|
* // Create a button component.
|
|
1430
2569
|
* const Button = Component.create`
|
|
1431
2570
|
* <button class="button">
|
|
1432
|
-
* ${({
|
|
2571
|
+
* ${({ props }) => props.children}
|
|
1433
2572
|
* </button>
|
|
1434
2573
|
* `;
|
|
1435
2574
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1436
2575
|
* const Navigation = Component.create`
|
|
1437
2576
|
* <nav>
|
|
1438
|
-
* ${({
|
|
1439
|
-
* item => Button.mount({
|
|
2577
|
+
* ${({ props }) => props.items.map(
|
|
2578
|
+
* item => Button.mount({ children : item.label })
|
|
1440
2579
|
* )}
|
|
1441
2580
|
* </nav>
|
|
1442
2581
|
* `;
|
|
1443
2582
|
* // Create a header component. Add navigation as a child.
|
|
1444
2583
|
* const Header = Component.create`
|
|
1445
2584
|
* <header>
|
|
1446
|
-
* ${({
|
|
2585
|
+
* ${({ props }) => Navigation.mount({ items : props.items})}
|
|
1447
2586
|
* </header>
|
|
1448
2587
|
* `;
|
|
1449
2588
|
* ```
|
|
@@ -1452,13 +2591,13 @@
|
|
|
1452
2591
|
* // Create a button component.
|
|
1453
2592
|
* const Button = Component.create`
|
|
1454
2593
|
* <button class="button">
|
|
1455
|
-
* ${({
|
|
2594
|
+
* ${({ props }) => props.children}
|
|
1456
2595
|
* </button>
|
|
1457
2596
|
* `;
|
|
1458
2597
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
1459
2598
|
* const Navigation = Component.create`
|
|
1460
2599
|
* <nav>
|
|
1461
|
-
* ${({
|
|
2600
|
+
* ${({ props, partial }) => props.items.map(
|
|
1462
2601
|
* item => partial`<${Button}>${item.label}</${Button}>`
|
|
1463
2602
|
* )}
|
|
1464
2603
|
* </nav>
|
|
@@ -1466,7 +2605,7 @@
|
|
|
1466
2605
|
* // Create a header component. Add navigation as a child.
|
|
1467
2606
|
* const Header = Component.create`
|
|
1468
2607
|
* <header>
|
|
1469
|
-
* <${Navigation} items="${({
|
|
2608
|
+
* <${Navigation} items="${({ props }) => props.items}" />
|
|
1470
2609
|
* </header>
|
|
1471
2610
|
* `;
|
|
1472
2611
|
* ```
|
|
@@ -1474,8 +2613,8 @@
|
|
|
1474
2613
|
* ```javascript
|
|
1475
2614
|
* // Create a button component.
|
|
1476
2615
|
* const Button = Component.create`
|
|
1477
|
-
* <button class="${({
|
|
1478
|
-
* ${
|
|
2616
|
+
* <button class="${({ props }) => props.className}">
|
|
2617
|
+
* ${({ props }) => props.children}
|
|
1479
2618
|
* </button>
|
|
1480
2619
|
* `;
|
|
1481
2620
|
* // Create a container that renders a Button component.
|
|
@@ -1484,107 +2623,113 @@
|
|
|
1484
2623
|
* `;
|
|
1485
2624
|
* // Create a container that renders a Button component, using a function.
|
|
1486
2625
|
* const ButtonCancel = Component.create(() => Button.mount({
|
|
1487
|
-
* className: 'cancel',
|
|
1488
|
-
*
|
|
2626
|
+
* className : 'cancel',
|
|
2627
|
+
* children : 'Cancel'
|
|
1489
2628
|
* }));
|
|
1490
2629
|
* ```
|
|
1491
2630
|
* @static
|
|
1492
|
-
* @param {string|
|
|
2631
|
+
* @param {string|Function} strings - HTML template for the component or a function that mounts a sub component.
|
|
1493
2632
|
* @param {...*} expressions - The expressions to be interpolated within the template.
|
|
1494
|
-
* @return {
|
|
2633
|
+
* @return {Component} The newly created component class.
|
|
1495
2634
|
*/
|
|
1496
2635
|
static create(strings, ...expressions) {
|
|
1497
|
-
const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
|
|
1498
2636
|
// Containers can be created using create as a functions instead of a tagged template.
|
|
1499
2637
|
if (typeof strings === 'function') {
|
|
1500
2638
|
expressions = [strings];
|
|
1501
2639
|
strings = ['', ''];
|
|
1502
2640
|
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
2641
|
+
// Create elements, interpolations and parts arrays.
|
|
2642
|
+
const elements = [], interpolations = [];
|
|
2643
|
+
const parts = splitPlaceholders(
|
|
2644
|
+
parseInterpolations(
|
|
2645
|
+
parseElements(
|
|
2646
|
+
expandComponents(
|
|
2647
|
+
addPlaceholders(
|
|
2648
|
+
strings,
|
|
2649
|
+
expressions
|
|
2650
|
+
).trim(),
|
|
2651
|
+
expressions
|
|
2652
|
+
),
|
|
2653
|
+
expressions,
|
|
2654
|
+
elements
|
|
2655
|
+
),
|
|
2656
|
+
expressions,
|
|
2657
|
+
interpolations
|
|
2658
|
+
),
|
|
2659
|
+
expressions
|
|
1510
2660
|
);
|
|
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();
|
|
2661
|
+
// Create subclass for this component.
|
|
2662
|
+
return this.extend({
|
|
2663
|
+
template() {
|
|
2664
|
+
return {
|
|
2665
|
+
elements : elements.map(element => new Element({
|
|
2666
|
+
getSelector : element.getSelector.bind(this),
|
|
2667
|
+
getAttributes : element.getAttributes.bind(this)
|
|
2668
|
+
})),
|
|
2669
|
+
interpolations : interpolations.map(interpolation => new Interpolation({
|
|
2670
|
+
getStart : interpolation.getStart.bind(this),
|
|
2671
|
+
getEnd : interpolation.getEnd.bind(this),
|
|
2672
|
+
expression : interpolation.expression,
|
|
2673
|
+
isComponent,
|
|
2674
|
+
isElement
|
|
2675
|
+
})),
|
|
2676
|
+
parts,
|
|
1560
2677
|
};
|
|
1561
|
-
} else {
|
|
1562
|
-
throw new SyntaxError('Invalid component');
|
|
1563
2678
|
}
|
|
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
2679
|
});
|
|
1578
2680
|
}
|
|
1579
2681
|
}
|
|
1580
2682
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
Component.
|
|
1585
|
-
Component.
|
|
2683
|
+
/*
|
|
2684
|
+
* Attributes used to identify elements and events.
|
|
2685
|
+
*/
|
|
2686
|
+
Component.ATTRIBUTE_ELEMENT = 'data-rasti-el';
|
|
2687
|
+
Component.ATTRIBUTE_EVENT = (type) => `data-rasti-on-${type}`;
|
|
2688
|
+
|
|
2689
|
+
/*
|
|
2690
|
+
* Placeholders used to temporarily replace expressions in the template.
|
|
2691
|
+
*/
|
|
2692
|
+
Component.PLACEHOLDER = (idx) => `__RASTI-${idx}__`;
|
|
2693
|
+
|
|
2694
|
+
/*
|
|
2695
|
+
* Markers used to identify interpolation and recycled components.
|
|
2696
|
+
*/
|
|
2697
|
+
Component.MARKER_RECYCLED = (uid) => `rasti-recycled-${uid}`;
|
|
2698
|
+
Component.MARKER_START = (uid) => `rasti-start-${uid}`;
|
|
2699
|
+
Component.MARKER_END = (uid) => `rasti-end-${uid}`;
|
|
2700
|
+
|
|
2701
|
+
/**
|
|
2702
|
+
* Components are a special kind of `View` that is designed to be easily composable,
|
|
2703
|
+
* making it simple to add child views and build complex user interfaces.
|
|
2704
|
+
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
2705
|
+
* guidelines that allow for a more declarative development style.
|
|
2706
|
+
* Components are defined with the {@link #module_component_create Component.create} static method, which takes a tagged template string or a function that returns another component.
|
|
2707
|
+
* @module
|
|
2708
|
+
* @extends View
|
|
2709
|
+
* @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onHydrate, onRecycle, onUpdate, onCreate, onChange. Any additional options not in the component or view options list will be automatically extracted as props and stored as `this.props`.
|
|
2710
|
+
* @property {string} [key] A unique key to identify the component. Components with keys are recycled when the same key is found in the previous render. Unkeyed components are recycled based on type and position.
|
|
2711
|
+
* @property {Rasti.Model} [model] A `Rasti.Model` or any emitter object containing data and business logic. The component will listen to `change` events and call `onChange` lifecycle method.
|
|
2712
|
+
* @property {Rasti.Model} [state] A `Rasti.Model` or any emitter object containing data and business logic, to be used as internal state. The component will listen to `change` events and call `onChange` lifecycle method.
|
|
2713
|
+
* @property {Rasti.Model} [props] Automatically created from any options not merged to the component instance. Contains props passed from parent component as a `Rasti.Model`. The component will listen to `change` events on props and call `onChange` lifecycle method. When a component with a `key` is recycled during parent re-render, new props are automatically updated and any changes trigger a re-render.
|
|
2714
|
+
* @see {@link #module_component_create Component.create}
|
|
2715
|
+
* @example
|
|
2716
|
+
* import { Component, Model } from 'rasti';
|
|
2717
|
+
* // Create Timer component.
|
|
2718
|
+
* const Timer = Component.create`
|
|
2719
|
+
* <div>
|
|
2720
|
+
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
2721
|
+
* </div>
|
|
2722
|
+
* `;
|
|
2723
|
+
* // Create model to store seconds.
|
|
2724
|
+
* const model = new Model({ seconds: 0 });
|
|
2725
|
+
* // Mount timer on body.
|
|
2726
|
+
* Timer.mount({ model }, document.body);
|
|
2727
|
+
* // Increment `model.seconds` every second.
|
|
2728
|
+
* setInterval(() => model.seconds++, 1000);
|
|
2729
|
+
*/
|
|
2730
|
+
var Component$1 = Component.create`<div></div>`;
|
|
1586
2731
|
|
|
1587
|
-
exports.Component = Component;
|
|
2732
|
+
exports.Component = Component$1;
|
|
1588
2733
|
exports.Emitter = Emitter;
|
|
1589
2734
|
exports.Model = Model;
|
|
1590
2735
|
exports.View = View;
|