rasti 3.0.0 → 4.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -14
- package/dist/rasti.js +1854 -679
- package/dist/rasti.min.js +1 -1
- package/es/Component.js +770 -479
- package/es/Emitter.js +182 -28
- package/es/Model.js +237 -51
- package/es/View.js +95 -53
- package/es/core/Element.js +55 -0
- package/es/core/EventsManager.js +41 -0
- package/es/core/Interpolation.js +70 -0
- package/es/core/InterpolationWrapper.js +14 -0
- package/es/core/Partial.js +12 -0
- package/es/core/PathManager.js +88 -0
- package/es/core/SafeHTML.js +17 -0
- package/es/index.js +13 -0
- package/es/utils/deepFlat.js +4 -2
- package/es/utils/findComment.js +42 -0
- package/es/utils/getAttributesDiff.js +33 -0
- package/es/utils/getAttributesHTML.js +25 -0
- package/es/utils/getResult.js +4 -2
- package/es/utils/parseHTML.js +14 -0
- package/es/utils/syncNode.js +109 -0
- package/es/utils/validateListener.js +14 -0
- package/lib/Component.cjs +771 -480
- package/lib/Emitter.cjs +182 -28
- package/lib/Model.cjs +237 -51
- package/lib/View.cjs +95 -53
- package/lib/core/Element.cjs +57 -0
- package/lib/core/EventsManager.cjs +43 -0
- package/lib/core/Interpolation.cjs +72 -0
- package/lib/core/InterpolationWrapper.cjs +16 -0
- package/lib/core/Partial.cjs +14 -0
- package/lib/core/PathManager.cjs +90 -0
- package/lib/core/SafeHTML.cjs +19 -0
- package/lib/index.cjs +13 -0
- package/lib/utils/deepFlat.cjs +4 -2
- package/lib/utils/findComment.cjs +44 -0
- package/lib/utils/getAttributesDiff.cjs +35 -0
- package/lib/utils/getAttributesHTML.cjs +27 -0
- package/lib/utils/getResult.cjs +4 -2
- package/lib/utils/parseHTML.cjs +16 -0
- package/lib/utils/syncNode.cjs +111 -0
- package/lib/utils/validateListener.cjs +16 -0
- package/package.json +11 -8
- package/src/Component.js +767 -478
- package/src/Emitter.js +182 -28
- package/src/Model.js +236 -51
- package/src/View.js +95 -53
- package/src/core/Element.js +55 -0
- package/src/core/EventsManager.js +41 -0
- package/src/core/Interpolation.js +70 -0
- package/src/core/InterpolationWrapper.js +14 -0
- package/src/core/Partial.js +12 -0
- package/src/core/PathManager.js +88 -0
- package/src/core/SafeHTML.js +17 -0
- package/src/index.js +4 -5
- package/src/utils/deepFlat.js +4 -2
- package/src/utils/findComment.js +40 -0
- package/src/utils/getAttributesDiff.js +31 -0
- package/src/utils/getAttributesHTML.js +23 -0
- package/src/utils/getResult.js +6 -2
- package/src/utils/parseHTML.js +12 -0
- package/src/utils/syncNode.js +107 -0
- package/src/utils/validateListener.js +12 -0
package/lib/Component.cjs
CHANGED
|
@@ -1,37 +1,58 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
require('./Emitter.cjs');
|
|
4
|
+
var Model = require('./Model.cjs');
|
|
3
5
|
var View = require('./View.cjs');
|
|
6
|
+
var core_SafeHTML = require('./core/SafeHTML.cjs');
|
|
7
|
+
var core_Partial = require('./core/Partial.cjs');
|
|
8
|
+
var core_InterpolationWrapper = require('./core/InterpolationWrapper.cjs');
|
|
9
|
+
var core_EventsManager = require('./core/EventsManager.cjs');
|
|
10
|
+
var core_PathManager = require('./core/PathManager.cjs');
|
|
11
|
+
var core_Element = require('./core/Element.cjs');
|
|
12
|
+
var core_Interpolation = require('./core/Interpolation.cjs');
|
|
13
|
+
var utils_validateListener = require('./utils/validateListener.cjs');
|
|
4
14
|
var utils_getResult = require('./utils/getResult.cjs');
|
|
5
15
|
var utils_deepFlat = require('./utils/deepFlat.cjs');
|
|
6
|
-
require('./
|
|
16
|
+
var utils_parseHTML = require('./utils/parseHTML.cjs');
|
|
17
|
+
var utils_findComment = require('./utils/findComment.cjs');
|
|
18
|
+
var utils_getAttributesHTML = require('./utils/getAttributesHTML.cjs');
|
|
19
|
+
require('./utils/getAttributesDiff.cjs');
|
|
20
|
+
require('./utils/syncNode.cjs');
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
* Wrapper class for HTML strings marked as safe.
|
|
10
|
-
*/
|
|
11
|
-
class SafeHTML {
|
|
12
|
-
constructor(value) {
|
|
13
|
-
this.value = value;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
toString() {
|
|
17
|
-
return this.value;
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/*
|
|
22
|
+
/**
|
|
22
23
|
* Same as getResult, but pass context as argument to the expression.
|
|
23
24
|
* Used to evaluate expressions in the context of a component.
|
|
24
25
|
* @param {any} expression The expression to be evaluated.
|
|
25
26
|
* @param {any} context The context to call the expression with.
|
|
26
27
|
* @return {any} The result of the evaluated expression.
|
|
28
|
+
* @private
|
|
27
29
|
*/
|
|
28
30
|
const getExpressionResult = (expression, context) => utils_getResult(expression, context, context);
|
|
29
31
|
|
|
30
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Check if an element is a component root element.
|
|
34
|
+
* Component root elements have the data attribute ending with '-1'.
|
|
35
|
+
* @param {Element} el The element to check.
|
|
36
|
+
* @return {boolean} True if the element is a component root element.
|
|
37
|
+
* @private
|
|
38
|
+
*/
|
|
39
|
+
const isComponent = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT) && el.getAttribute(Component.ATTRIBUTE_ELEMENT).endsWith('-1');
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if an element has the Rasti data attribute.
|
|
43
|
+
* This includes both component root elements and regular tracked elements.
|
|
44
|
+
* @param {Element} el The element to check.
|
|
45
|
+
* @return {boolean} True if the element has the data attribute.
|
|
46
|
+
* @private
|
|
47
|
+
*/
|
|
48
|
+
const isElement = (el) => el.hasAttribute(Component.ATTRIBUTE_ELEMENT);
|
|
49
|
+
|
|
50
|
+
/**
|
|
31
51
|
* Generate string with placeholders for interpolated expressions.
|
|
32
|
-
* @param
|
|
33
|
-
* @param
|
|
52
|
+
* @param {Array<string>} strings Array of strings.
|
|
53
|
+
* @param {Array<any>} expressions Array of expressions.
|
|
34
54
|
* @return {string} String with placeholders.
|
|
55
|
+
* @private
|
|
35
56
|
*/
|
|
36
57
|
const addPlaceholders = (strings, expressions) =>
|
|
37
58
|
strings.reduce((out, string, i) => {
|
|
@@ -39,19 +60,20 @@ const addPlaceholders = (strings, expressions) =>
|
|
|
39
60
|
out.push(string);
|
|
40
61
|
// Add expression placeholders.
|
|
41
62
|
if (typeof expressions[i] !== 'undefined') {
|
|
42
|
-
out.push(Component.
|
|
63
|
+
out.push(Component.PLACEHOLDER(i));
|
|
43
64
|
}
|
|
44
65
|
return out;
|
|
45
66
|
}, []).join('');
|
|
46
67
|
|
|
47
|
-
|
|
68
|
+
/**
|
|
48
69
|
* Generate one dimensional array with strings and expressions.
|
|
49
70
|
* @param main {string} The main template containing placeholders.
|
|
50
|
-
* @param
|
|
71
|
+
* @param {Array<any>} expressions Array of expressions to replace placeholders.
|
|
51
72
|
* @return {array} Array containing strings and expressions.
|
|
73
|
+
* @private
|
|
52
74
|
*/
|
|
53
75
|
const splitPlaceholders = (main, expressions) => {
|
|
54
|
-
const PH = Component.
|
|
76
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
55
77
|
const regExp = new RegExp(`${PH}`, 'g');
|
|
56
78
|
const out = [];
|
|
57
79
|
let lastIndex = 0;
|
|
@@ -68,363 +90,539 @@ const splitPlaceholders = (main, expressions) => {
|
|
|
68
90
|
return out;
|
|
69
91
|
};
|
|
70
92
|
|
|
71
|
-
|
|
93
|
+
/**
|
|
72
94
|
* Expand attributes.
|
|
73
|
-
* @param
|
|
74
|
-
* @param
|
|
95
|
+
* @param {Array<Array<any>>} attributes Array of attributes as key, value pairs.
|
|
96
|
+
* @param {Function} getExpressionResult Function to render expressions.
|
|
75
97
|
* @return {object}
|
|
76
98
|
* @property {object} all All attributes.
|
|
77
99
|
* @property {object} events Event listeners.
|
|
78
100
|
* @property {object} attributes Attributes.
|
|
101
|
+
* @private
|
|
79
102
|
*/
|
|
80
|
-
const expandAttributes = (attributes, getExpressionResult) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
out.all[attribute] = true;
|
|
91
|
-
}
|
|
92
|
-
} else {
|
|
93
|
-
// Attribute with value.
|
|
94
|
-
const value = getExpressionResult(pair[1]);
|
|
95
|
-
out.all[attribute] = value;
|
|
103
|
+
const expandAttributes = (attributes, getExpressionResult) => attributes.reduce((out, pair) => {
|
|
104
|
+
const attribute = getExpressionResult(pair[0]);
|
|
105
|
+
// Attribute without value.
|
|
106
|
+
if (pair.length === 1) {
|
|
107
|
+
if (typeof attribute === 'object') {
|
|
108
|
+
// Expand objects as attributes.
|
|
109
|
+
out = Object.assign(out, attribute);
|
|
110
|
+
} else if (typeof attribute === 'string') {
|
|
111
|
+
// Treat as boolean.
|
|
112
|
+
out[attribute] = true;
|
|
96
113
|
}
|
|
114
|
+
} else {
|
|
115
|
+
// Attribute with value.
|
|
116
|
+
const value = pair[2] ? getExpressionResult(pair[1]) : pair[1];
|
|
117
|
+
out[attribute] = value;
|
|
118
|
+
}
|
|
97
119
|
|
|
98
|
-
|
|
99
|
-
|
|
120
|
+
return out;
|
|
121
|
+
}, {});
|
|
100
122
|
|
|
101
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Expand events.
|
|
125
|
+
* @param {object} attributes Attributes object.
|
|
126
|
+
* @param {EventsManager} eventsManager Events manager.
|
|
127
|
+
* @return {object} Attributes object.
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
const expandEvents = (attributes, eventsManager) => {
|
|
131
|
+
const out = {};
|
|
132
|
+
Object.keys(attributes).forEach(key => {
|
|
102
133
|
// Check if key is an event listener.
|
|
103
134
|
const match = key.match(/on(([A-Z]{1}[a-z]+)+)/);
|
|
135
|
+
|
|
104
136
|
if (match && match[1]) {
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
const type = match[1].toLowerCase();
|
|
138
|
+
const listener = attributes[key];
|
|
139
|
+
if (listener) {
|
|
140
|
+
const index = eventsManager.addListener(listener, type);
|
|
141
|
+
// Add event listener index.
|
|
142
|
+
out[Component.ATTRIBUTE_EVENT(type)] = index;
|
|
143
|
+
}
|
|
107
144
|
} else {
|
|
108
145
|
// Add attribute.
|
|
109
|
-
out
|
|
146
|
+
out[key] = attributes[key];
|
|
110
147
|
}
|
|
111
148
|
});
|
|
112
|
-
|
|
113
149
|
return out;
|
|
114
150
|
};
|
|
115
151
|
|
|
116
|
-
|
|
152
|
+
/**
|
|
117
153
|
* Replace component tags with expressions.
|
|
118
154
|
* `<${Component} />` or `<${Component}></${Component}>` will be replaced
|
|
119
155
|
* by a function that mounts the component.
|
|
120
156
|
* Returns the template with component tags replaced by expressions placeholders
|
|
121
157
|
* modifies the expressions array adding the mount functions.
|
|
122
158
|
* @param main {string} The main template.
|
|
159
|
+
* @param {Array<any>} expressions Array of expressions.
|
|
123
160
|
* @return {string} The template with components tags replaced by expressions
|
|
124
161
|
* placeholders.
|
|
162
|
+
* @private
|
|
125
163
|
*/
|
|
126
164
|
const expandComponents = (main, expressions) => {
|
|
127
|
-
const PH = Component.
|
|
165
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
128
166
|
// Match component tags.
|
|
129
167
|
return main.replace(
|
|
130
168
|
new RegExp(`<(${PH})([^>]*)>([\\s\\S]*?)</(${PH})>|<(${PH})([^>]*)/>`,'g'),
|
|
131
|
-
|
|
132
|
-
|
|
169
|
+
(match, openTag, openIdx, nonVoidAttrs, inner, closeTag, closeIdx, selfClosingTag, selfClosingIdx, selfClosingAttrs) => {
|
|
170
|
+
let tag, close, attributesStr;
|
|
171
|
+
|
|
172
|
+
if (openTag) {
|
|
173
|
+
tag = typeof openIdx !== 'undefined' ? expressions[openIdx] : openTag;
|
|
174
|
+
close = typeof closeIdx !== 'undefined' ? expressions[closeIdx] : closeTag;
|
|
175
|
+
attributesStr = nonVoidAttrs;
|
|
176
|
+
} else {
|
|
177
|
+
tag = typeof selfClosingIdx !== 'undefined' ? expressions[selfClosingIdx] : selfClosingTag;
|
|
178
|
+
attributesStr = selfClosingAttrs;
|
|
179
|
+
}
|
|
133
180
|
// No component found.
|
|
134
|
-
if (!(tag.prototype instanceof Component)) return
|
|
181
|
+
if (!(tag.prototype instanceof Component)) return match;
|
|
135
182
|
|
|
136
|
-
let
|
|
183
|
+
let innerList;
|
|
137
184
|
// Non void component.
|
|
138
185
|
if (close) {
|
|
139
186
|
// Close component tag must match open component tag.
|
|
140
|
-
if (tag !== close) return
|
|
187
|
+
if (tag !== close) return match;
|
|
188
|
+
// Process inner content same way as partial().
|
|
141
189
|
// Recursively expand inner components.
|
|
142
|
-
const
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
190
|
+
const innerTemplate = expandComponents(inner, expressions);
|
|
191
|
+
// Parse partial elements to handle dynamic attributes and events.
|
|
192
|
+
const parsedInner = parsePartialElements(innerTemplate, expressions);
|
|
193
|
+
// Split into items.
|
|
194
|
+
innerList = splitPlaceholders(parsedInner, expressions);
|
|
147
195
|
}
|
|
196
|
+
// Parse attributes.
|
|
197
|
+
const attributes = parseAttributes(attributesStr, expressions);
|
|
148
198
|
// Create mount function.
|
|
149
199
|
const mount = function() {
|
|
150
|
-
const options = expandAttributes(attributes, value => getExpressionResult(value, this))
|
|
151
|
-
// Add renderChildren function to options.
|
|
152
|
-
if (
|
|
200
|
+
const options = expandAttributes(attributes, value => getExpressionResult(value, this));
|
|
201
|
+
// Add `renderChildren` function to options.
|
|
202
|
+
if (innerList) {
|
|
203
|
+
// Evaluate items in parent context and create Partial.
|
|
204
|
+
options.renderChildren = () => new core_Partial(innerList.map(item => getExpressionResult(item, this)));
|
|
205
|
+
}
|
|
153
206
|
// Mount component.
|
|
154
207
|
return tag.mount(options);
|
|
155
208
|
};
|
|
156
209
|
// Add mount function to expression.
|
|
157
210
|
expressions.push(mount);
|
|
158
211
|
// Replace whole string with expression placeholder.
|
|
159
|
-
return Component.
|
|
212
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
160
213
|
}
|
|
161
214
|
);
|
|
162
215
|
};
|
|
163
216
|
|
|
164
|
-
|
|
165
|
-
*
|
|
166
|
-
* @param
|
|
167
|
-
* @
|
|
168
|
-
* @
|
|
169
|
-
* @
|
|
170
|
-
* @property {string} close The closing tag.
|
|
171
|
-
* @property {array} attributes Array of attributes as key, value pairs.
|
|
172
|
-
* @property {string} raw The whole match.
|
|
217
|
+
/**
|
|
218
|
+
* Replace elements in template.
|
|
219
|
+
* @param {string} template Template string.
|
|
220
|
+
* @param {Function} replacer Replacer function.
|
|
221
|
+
* @return {string} Template string with replaced elements.
|
|
222
|
+
* @private
|
|
173
223
|
*/
|
|
174
|
-
const
|
|
175
|
-
const PH = Component.
|
|
224
|
+
const replaceElements = (template, replacer) => {
|
|
225
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
226
|
+
return template.replace(
|
|
227
|
+
new RegExp(`<(${PH}|[a-z]+[1-6]?)(?:\\s*)((?:"[^"]*"|'[^']*'|[^>])*)(/?>)`, 'gi'),
|
|
228
|
+
replacer
|
|
229
|
+
);
|
|
230
|
+
};
|
|
176
231
|
|
|
177
|
-
|
|
178
|
-
|
|
232
|
+
/**
|
|
233
|
+
* Parse all HTML elements in template and extract their attributes.
|
|
234
|
+
* @param {string} template Template string with placeholders.
|
|
235
|
+
* @param {Array} expressions Array of expressions.
|
|
236
|
+
* @param {Array} elements Array to store element references.
|
|
237
|
+
* @return {string} Template with parsed attributes.
|
|
238
|
+
* @throws {SyntaxError} If the template does not have a single root element or is a container component.
|
|
239
|
+
* @private
|
|
240
|
+
*/
|
|
241
|
+
const parseElements = (template, expressions, elements) => {
|
|
242
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
243
|
+
// Check if template is a container (single placeholder, no tag).
|
|
244
|
+
const containerMatch = template.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
245
|
+
if (containerMatch) return template;
|
|
246
|
+
// Validate that template has a root element.
|
|
247
|
+
const rootElementMatch = template.match(new RegExp(`^\\s*<([a-z]+[1-6]?|${PH})([^>]*)>([\\s\\S]*?)</(\\1|${PH})>\\s*$|^\\s*<([a-z]+[1-6]?|${PH})([^>]*)/>\\s*$`));
|
|
248
|
+
if (!rootElementMatch) throw new SyntaxError(`Template must have a single root element or be a container component: "${template.trim()}"`);
|
|
249
|
+
|
|
250
|
+
let elementUid = 0;
|
|
251
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
252
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
253
|
+
const isRoot = elementUid === 0;
|
|
254
|
+
const currentElementUid = ++elementUid;
|
|
255
|
+
// If there are no dynamic attributes, return original match.
|
|
256
|
+
if (!isRoot && !attributesStr.match(new RegExp(PH))) {
|
|
257
|
+
return match;
|
|
258
|
+
}
|
|
259
|
+
// Parse attributes.
|
|
260
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
261
|
+
// Create element reference.
|
|
262
|
+
const generateElementUid = componentUid => `${componentUid}-${currentElementUid}`;
|
|
263
|
+
// Create function that returns attributes object.
|
|
264
|
+
const getAttributes = function() {
|
|
265
|
+
// Expand attributes and events.
|
|
266
|
+
const attributes = expandEvents(
|
|
267
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
268
|
+
this.eventsManager
|
|
269
|
+
);
|
|
270
|
+
// Extend template attributes with `options.attributes`.
|
|
271
|
+
if (isRoot && this.attributes) {
|
|
272
|
+
Object.assign(attributes, utils_getResult(this.attributes, this));
|
|
273
|
+
}
|
|
274
|
+
// Add data attribute for element identification.
|
|
275
|
+
// First element gets the component uid, others get element uid.
|
|
276
|
+
attributes[Component.ATTRIBUTE_ELEMENT] = generateElementUid(this.uid);
|
|
277
|
+
|
|
278
|
+
return attributes;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const getSelector = function() {
|
|
282
|
+
return `[${Component.ATTRIBUTE_ELEMENT}="${generateElementUid(this.uid)}"]`;
|
|
283
|
+
};
|
|
284
|
+
// Add element reference to elements array.
|
|
285
|
+
elements.push({
|
|
286
|
+
getSelector,
|
|
287
|
+
getAttributes,
|
|
288
|
+
});
|
|
289
|
+
// Add new expression to expressions array.
|
|
290
|
+
expressions.push(function() {
|
|
291
|
+
const attributes = getAttributes.call(this);
|
|
292
|
+
return Component.markAsSafeHTML(utils_getAttributesHTML(attributes));
|
|
293
|
+
});
|
|
294
|
+
// Replace attributes with placeholder.
|
|
295
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
296
|
+
// Preserve original tag ending (> or />)
|
|
297
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
298
|
+
});
|
|
299
|
+
};
|
|
179
300
|
|
|
180
|
-
|
|
301
|
+
/**
|
|
302
|
+
* Parse elements in partial template.
|
|
303
|
+
* @param {string} template Template string with placeholders.
|
|
304
|
+
* @param {Array} expressions Array of expressions.
|
|
305
|
+
* @return {string} Template with parsed attributes.
|
|
306
|
+
* @private
|
|
307
|
+
*/
|
|
308
|
+
const parsePartialElements = (template, expressions) => {
|
|
309
|
+
const PH = Component.PLACEHOLDER('(?:\\d+)');
|
|
310
|
+
// Match all HTML elements including placeholders and self-closed elements.
|
|
311
|
+
return replaceElements(template, (match, tag, attributesStr, ending) => {
|
|
312
|
+
// If there are no dynamic attributes, return original match.
|
|
313
|
+
if (!attributesStr.match(new RegExp(PH))) {
|
|
314
|
+
return match;
|
|
315
|
+
}
|
|
316
|
+
// Parse attributes.
|
|
317
|
+
const parsedAttributes = parseAttributes(attributesStr, expressions);
|
|
318
|
+
// Create function that returns attributes object.
|
|
319
|
+
const getAttributes = function() {
|
|
320
|
+
const attributes = expandEvents(
|
|
321
|
+
expandAttributes(parsedAttributes, value => getExpressionResult(value, this)),
|
|
322
|
+
this.eventsManager
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
return attributes;
|
|
326
|
+
};
|
|
327
|
+
// Add new expression to expressions array.
|
|
328
|
+
expressions.push(function() {
|
|
329
|
+
const attributes = getAttributes.call(this);
|
|
330
|
+
return Component.markAsSafeHTML(utils_getAttributesHTML(attributes));
|
|
331
|
+
});
|
|
332
|
+
// Replace attributes with placeholder.
|
|
333
|
+
const placeholder = Component.PLACEHOLDER(expressions.length - 1);
|
|
334
|
+
// Preserve original tag ending (> or />)
|
|
335
|
+
return `<${tag} ${placeholder}${ending}`;
|
|
336
|
+
});
|
|
337
|
+
};
|
|
181
338
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Parse all interpolations in template text content.
|
|
341
|
+
* @param {string} template Template string with placeholders.
|
|
342
|
+
* @param {Array} expressions Array of expressions.
|
|
343
|
+
* @param {Array} interpolations Array to store interpolation references.
|
|
344
|
+
* @return {string} Template with interpolation markers.
|
|
345
|
+
* @private
|
|
346
|
+
*/
|
|
347
|
+
const parseInterpolations = (template, expressions, interpolations) => {
|
|
348
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
349
|
+
let interpolationUid = 0;
|
|
350
|
+
// Match all expression placeholders.
|
|
351
|
+
return template.replace(
|
|
352
|
+
new RegExp(PH, 'g'),
|
|
353
|
+
function(match, expressionIndex, offset) {
|
|
354
|
+
// Check if this placeholder is inside an element tag (attribute).
|
|
355
|
+
// `offset` is the index of the match in the original string.
|
|
356
|
+
const beforeMatch = template.substring(0, offset);
|
|
357
|
+
const lastOpenTag = beforeMatch.lastIndexOf('<');
|
|
358
|
+
const lastCloseTag = beforeMatch.lastIndexOf('>');
|
|
359
|
+
// If we're inside an element tag, don't process as interpolation.
|
|
360
|
+
if (lastOpenTag > lastCloseTag) {
|
|
361
|
+
return match;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const currentInterpolationUid = ++interpolationUid;
|
|
365
|
+
|
|
366
|
+
function getStart() {
|
|
367
|
+
return Component.MARKER_START(`${this.uid}-${currentInterpolationUid}`);
|
|
368
|
+
}
|
|
369
|
+
function getEnd() {
|
|
370
|
+
return Component.MARKER_END(`${this.uid}-${currentInterpolationUid}`);
|
|
371
|
+
}
|
|
372
|
+
// Add interpolation reference to interpolations array.
|
|
373
|
+
interpolations.push({
|
|
374
|
+
getStart,
|
|
375
|
+
getEnd,
|
|
376
|
+
expression : expressions[expressionIndex]
|
|
377
|
+
});
|
|
378
|
+
// Add new expression to expressions array.
|
|
379
|
+
expressions.push(function() {
|
|
380
|
+
const result = getExpressionResult(expressions[expressionIndex], this);
|
|
381
|
+
const uid = `${this.uid}-${currentInterpolationUid}`;
|
|
382
|
+
return new core_InterpolationWrapper(uid, result);
|
|
383
|
+
});
|
|
384
|
+
// Replace with new placeholder.
|
|
385
|
+
return Component.PLACEHOLDER(expressions.length - 1);
|
|
386
|
+
}
|
|
387
|
+
);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Parse attributes string to extract dynamic attributes.
|
|
392
|
+
* @param {string} attributesStr Attributes string from HTML element.
|
|
393
|
+
* @param {Array} expressions Array of expressions.
|
|
394
|
+
* @return {Array} Array of attribute pairs [key, value] or [key, value, hasQuotes].
|
|
395
|
+
* @private
|
|
396
|
+
*/
|
|
397
|
+
const parseAttributes = (attributesStr, expressions) => {
|
|
398
|
+
const PH = Component.PLACEHOLDER('(\\d+)');
|
|
399
|
+
const attributes = [];
|
|
400
|
+
// Parse attributes string with support for placeholders in both names and values.
|
|
195
401
|
const regExp = new RegExp(`(${PH}|[\\w-]+)(?:=(["']?)(?:${PH}|((?:.?(?!["']?\\s+(?:\\S+)=|\\s*/?[>"']))+.))\\3)?`, 'g');
|
|
196
402
|
|
|
197
403
|
let attributeMatch;
|
|
198
404
|
while ((attributeMatch = regExp.exec(attributesStr)) !== null) {
|
|
199
|
-
const [, attribute, attributeIdx
|
|
405
|
+
const [, attribute, attributeIdx, quotes, valueIdx, value] = attributeMatch;
|
|
200
406
|
|
|
201
|
-
const attr = typeof attributeIdx !== 'undefined' ? expressions[attributeIdx] : attribute;
|
|
202
|
-
const val = typeof valueIdx !== 'undefined' ? expressions[valueIdx] : value;
|
|
407
|
+
const attr = typeof attributeIdx !== 'undefined' ? expressions[parseInt(attributeIdx, 10)] : attribute;
|
|
408
|
+
const val = typeof valueIdx !== 'undefined' ? expressions[parseInt(valueIdx, 10)] : value;
|
|
203
409
|
|
|
204
410
|
if (typeof val !== 'undefined') {
|
|
205
|
-
|
|
411
|
+
attributes.push([attr, val, !!quotes]);
|
|
206
412
|
} else {
|
|
207
|
-
|
|
413
|
+
attributes.push([attr]);
|
|
208
414
|
}
|
|
209
415
|
}
|
|
210
416
|
|
|
211
|
-
return
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
/*
|
|
215
|
-
* HTML tags that are self closing.
|
|
216
|
-
*/
|
|
217
|
-
const selfClosingTags = {
|
|
218
|
-
area : true, base : true, br : true, col : true, embed : true, hr : true,
|
|
219
|
-
img : true, input : true, link : true, meta : true, source : true, track : true, wbr : true
|
|
417
|
+
return attributes;
|
|
220
418
|
};
|
|
221
419
|
|
|
222
420
|
/*
|
|
223
421
|
* These option keys will be extended on the component instance.
|
|
224
422
|
*/
|
|
225
|
-
const componentOptions =
|
|
226
|
-
key : true,
|
|
227
|
-
state : true,
|
|
228
|
-
onCreate : true,
|
|
229
|
-
onChange : true,
|
|
230
|
-
onRender : true
|
|
231
|
-
};
|
|
423
|
+
const componentOptions = ['key', 'state', 'onCreate', 'onChange', 'onHydrate', 'onRecycle', 'onUpdate'];
|
|
232
424
|
|
|
233
425
|
/**
|
|
234
|
-
*
|
|
235
|
-
* making it simple to add child views and build complex user interfaces.
|
|
236
|
-
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
237
|
-
* guidelines that allow for a more declarative development style.
|
|
238
|
-
* 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.
|
|
239
|
-
* @module
|
|
240
|
-
* @extends Rasti.View
|
|
241
|
-
* @param {object} options Object containing options. The following keys will be merged to `this`: model, state, key, onDestroy, onRender, onCreate, onChange.
|
|
242
|
-
* @property {string} key A unique key to identify the component. Used to recycle child components.
|
|
243
|
-
* @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.
|
|
244
|
-
* @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.
|
|
245
|
-
* @see {@link #module_component_create Component.create}
|
|
246
|
-
* @example
|
|
247
|
-
* import { Component, Model } from 'rasti';
|
|
248
|
-
* // Create Timer component.
|
|
249
|
-
* const Timer = Component.create`
|
|
250
|
-
* <div>
|
|
251
|
-
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
252
|
-
* </div>
|
|
253
|
-
* `;
|
|
254
|
-
* // Create model to store seconds.
|
|
255
|
-
* const model = new Model({ seconds: 0 });
|
|
256
|
-
* // Mount timer on body.
|
|
257
|
-
* Timer.mount({ model }, document.body);
|
|
258
|
-
* // Increment `model.seconds` every second.
|
|
259
|
-
* setInterval(() => model.seconds++, 1000);
|
|
426
|
+
* @lends module:Component
|
|
260
427
|
*/
|
|
261
428
|
class Component extends View {
|
|
262
429
|
constructor(options = {}) {
|
|
263
430
|
super(...arguments);
|
|
264
|
-
|
|
431
|
+
this.componentOptions = [];
|
|
432
|
+
// Extend "this" with options.
|
|
433
|
+
componentOptions.forEach(key => {
|
|
434
|
+
if (key in options) {
|
|
435
|
+
this[key] = options[key];
|
|
436
|
+
this.componentOptions.push(key);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
// Extract props from options that aren't component or view options.
|
|
440
|
+
const props = {};
|
|
265
441
|
Object.keys(options).forEach(key => {
|
|
266
|
-
if (
|
|
442
|
+
if (!this.viewOptions.includes(key) && !this.componentOptions.includes(key)) {
|
|
443
|
+
props[key] = options[key];
|
|
444
|
+
}
|
|
267
445
|
});
|
|
446
|
+
// Store props as Model for reactive updates.
|
|
447
|
+
this.props = new Model(props);
|
|
268
448
|
// Store options by default.
|
|
269
449
|
this.options = options;
|
|
270
450
|
// Bind `partial` method to `this`.
|
|
271
451
|
this.partial = this.partial.bind(this);
|
|
452
|
+
// Bind `onChange` method to `this`.
|
|
453
|
+
this.onChange = this.onChange.bind(this);
|
|
272
454
|
// Call lifecycle method.
|
|
273
455
|
this.onCreate.apply(this, arguments);
|
|
274
456
|
}
|
|
275
457
|
|
|
276
458
|
/**
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* @param {Rasti.Model} model A model or emitter object to listen to changes.
|
|
281
|
-
* @return {Rasti.Component} The component instance.
|
|
459
|
+
* Get events object for automatic event delegation, based on data attributes.
|
|
460
|
+
* @return {object} The events object.
|
|
461
|
+
* @private
|
|
282
462
|
*/
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
463
|
+
events() {
|
|
464
|
+
const events = {};
|
|
465
|
+
// Create events object.
|
|
466
|
+
this.eventsManager.types.forEach(type => {
|
|
467
|
+
const dataAttribute = Component.ATTRIBUTE_EVENT(type);
|
|
468
|
+
// Create a listener function that gets the listener index from the data attribute and calls the listener.
|
|
469
|
+
const listener = function(event, component, matched) {
|
|
470
|
+
// Get the listener index from the data attribute.
|
|
471
|
+
const index = matched.getAttribute(dataAttribute);
|
|
472
|
+
// Root element listener may not have a data attribute.
|
|
473
|
+
if (index) {
|
|
474
|
+
let currentListener = this.eventsManager.listeners[parseInt(index, 10)];
|
|
475
|
+
if (typeof currentListener === 'string') currentListener = this[currentListener];
|
|
476
|
+
utils_validateListener(currentListener);
|
|
477
|
+
// Call the listener.
|
|
478
|
+
currentListener.call(this, event, component, matched);
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
// Add an event listener to the events object for each event type, using the data attribute
|
|
482
|
+
// as both a CSS selector and to store the listener's index.
|
|
483
|
+
events[`${type} [${dataAttribute}]`] = listener;
|
|
484
|
+
// Add an event listener to the events object for each event type that matches the root element.
|
|
485
|
+
events[type] = listener;
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
return events;
|
|
489
|
+
}
|
|
297
490
|
|
|
491
|
+
/**
|
|
492
|
+
* Subscribes to a `change` event on a model or emitter object and invokes the `onChange` lifecycle method.
|
|
493
|
+
* The subscription is automatically cleaned up when the component is destroyed.
|
|
494
|
+
* By default, the component subscribes to changes on `this.model`, `this.state`, and `this.props`.
|
|
495
|
+
*
|
|
496
|
+
* @param {Object} model - The model or emitter object to listen to.
|
|
497
|
+
* @param {string} [type='change'] - The event type to listen for.
|
|
498
|
+
* @param {Function} [listener=this.onChange] - The callback to invoke when the event is emitted.
|
|
499
|
+
* @returns {Component} The current component instance for chaining.
|
|
500
|
+
*/
|
|
501
|
+
subscribe(model, type = 'change', listener = this.onChange) {
|
|
502
|
+
// Check if model has `on` method.
|
|
503
|
+
if (model.on) this.listenTo(model, type, listener);
|
|
298
504
|
return this;
|
|
299
505
|
}
|
|
300
506
|
|
|
301
|
-
|
|
302
|
-
* Tell if
|
|
507
|
+
/**
|
|
508
|
+
* Tell if `Component` is a container.
|
|
303
509
|
* In which case, it will not have an element by itself.
|
|
304
510
|
* It will render a single expression which is expected to return a single component as child.
|
|
305
511
|
* `this.el` will be a reference to that child component's element.
|
|
306
512
|
* @return {boolean}
|
|
513
|
+
* @private
|
|
307
514
|
*/
|
|
308
515
|
isContainer() {
|
|
309
|
-
return
|
|
516
|
+
return this.template.elements.length === 0 && this.template.interpolations.length === 1;
|
|
310
517
|
}
|
|
311
518
|
|
|
312
|
-
|
|
313
|
-
* Override. We don't want to ensure an element on instantiation.
|
|
519
|
+
/**
|
|
520
|
+
* Override super method. We don't want to ensure an element on instantiation.
|
|
314
521
|
* We will provide it later.
|
|
522
|
+
* @private
|
|
315
523
|
*/
|
|
316
524
|
ensureElement() {
|
|
525
|
+
// Store data event listeners.
|
|
526
|
+
this.eventsManager = new core_EventsManager();
|
|
527
|
+
// Store position tracking for recycling.
|
|
528
|
+
this.pathManager = new core_PathManager();
|
|
529
|
+
// Call template function.
|
|
530
|
+
this.template = utils_getResult(this.template, this);
|
|
317
531
|
// If el is provided, delegate events.
|
|
318
532
|
if (this.el) {
|
|
319
533
|
// If "this.el" is a function, call it to get the element.
|
|
320
534
|
this.el = utils_getResult(this.el, this);
|
|
321
|
-
|
|
535
|
+
// Render the component as a string to generate children components.
|
|
536
|
+
this.toString();
|
|
537
|
+
// Hydrate the component.
|
|
538
|
+
this.hydrate(this.el.parentNode);
|
|
322
539
|
}
|
|
323
540
|
}
|
|
324
541
|
|
|
325
|
-
|
|
326
|
-
* Find view's element on parent node, using its data attribute.
|
|
327
|
-
* @param parent {node} The parent node.
|
|
328
|
-
* @return {node} The component's element.
|
|
329
|
-
*/
|
|
330
|
-
findElement(parent) {
|
|
331
|
-
return (parent || document).querySelector(`[${Component.DATA_ATTRIBUTE_UID}="${this.uid}"]`);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
/*
|
|
335
|
-
* Eval attributes expressions.
|
|
336
|
-
* @return {object} Object containing add, remove and html properties.
|
|
337
|
-
*/
|
|
338
|
-
getAttributes() {
|
|
339
|
-
const add = {};
|
|
340
|
-
const remove = {};
|
|
341
|
-
const html = [];
|
|
342
|
-
|
|
343
|
-
const attributes = { [Component.DATA_ATTRIBUTE_UID] : this.uid };
|
|
344
|
-
|
|
345
|
-
if (this.attributes) Object.assign(attributes, utils_getResult(this.attributes, this));
|
|
346
|
-
// Store previous attributes.
|
|
347
|
-
const previousAttributes = this.previousAttributes || {};
|
|
348
|
-
this.previousAttributes = attributes;
|
|
349
|
-
|
|
350
|
-
Object.keys(attributes).forEach(key => {
|
|
351
|
-
let value = attributes[key];
|
|
352
|
-
// Transform bool attribute values
|
|
353
|
-
if (value === false) {
|
|
354
|
-
remove[key] = true;
|
|
355
|
-
} else if (value === true) {
|
|
356
|
-
add[key] = '';
|
|
357
|
-
html.push(key);
|
|
358
|
-
} else {
|
|
359
|
-
if (value === null || typeof value === 'undefined') value = '';
|
|
360
|
-
|
|
361
|
-
add[key] = value;
|
|
362
|
-
html.push(`${Component.sanitize(key)}="${Component.sanitize(value)}"`);
|
|
363
|
-
}
|
|
364
|
-
});
|
|
365
|
-
// Remove attributes that were in previousAttributes but not in current attributes.
|
|
366
|
-
Object.keys(previousAttributes).forEach(key => {
|
|
367
|
-
if (!(key in attributes)) {
|
|
368
|
-
remove[key] = true;
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
return { add, remove, html : html.join(' ') };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
/*
|
|
542
|
+
/**
|
|
376
543
|
* Used internally on the render process.
|
|
377
|
-
* Attach the
|
|
544
|
+
* Attach the `Component` to the dom element providing `this.el`, delegate events,
|
|
545
|
+
* subscribe to model changes and call `onHydrate` lifecycle method.
|
|
378
546
|
* @param parent {node} The parent node.
|
|
379
|
-
* @return {
|
|
547
|
+
* @return {Component} The component instance.
|
|
548
|
+
* @private
|
|
380
549
|
*/
|
|
381
550
|
hydrate(parent) {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (this.state) this.subscribe(this.state);
|
|
551
|
+
['model', 'state', 'props'].forEach(key => {
|
|
552
|
+
if (this[key]) this.subscribe(this[key]);
|
|
553
|
+
});
|
|
386
554
|
|
|
387
|
-
if (
|
|
388
|
-
|
|
389
|
-
this.
|
|
390
|
-
|
|
391
|
-
} else {
|
|
555
|
+
if (this.isContainer()) {
|
|
556
|
+
// Get references for interpolation marker comments
|
|
557
|
+
this.template.interpolations[0].hydrate(parent);
|
|
558
|
+
// Call hydrate on children.
|
|
392
559
|
this.children[0].hydrate(parent);
|
|
560
|
+
// Set the first element as the component's element.
|
|
393
561
|
this.el = this.children[0].el;
|
|
562
|
+
} else {
|
|
563
|
+
// Search for every element in template using getSelector
|
|
564
|
+
this.template.elements.forEach((element, index) => {
|
|
565
|
+
if (index === 0) {
|
|
566
|
+
element.hydrate(parent);
|
|
567
|
+
if (this.el) element.ref = this.el;
|
|
568
|
+
else this.el = element.ref;
|
|
569
|
+
}
|
|
570
|
+
else {
|
|
571
|
+
element.hydrate(this.el);
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
// Delegate events.
|
|
575
|
+
this.delegateEvents();
|
|
576
|
+
// Get references for interpolation marker comments
|
|
577
|
+
this.template.interpolations.forEach(interpolation => interpolation.hydrate(this.el));
|
|
578
|
+
this.children.forEach(child => child.hydrate(this.el));
|
|
394
579
|
}
|
|
395
|
-
// Call `
|
|
396
|
-
this.
|
|
580
|
+
// Call `onHydrate` lifecycle method.
|
|
581
|
+
this.onHydrate.call(this);
|
|
397
582
|
// Return `this` for chaining.
|
|
398
583
|
return this;
|
|
399
584
|
}
|
|
400
585
|
|
|
401
|
-
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* @
|
|
405
|
-
* @
|
|
586
|
+
/**
|
|
587
|
+
* Get a `comment` marker with same data attribute as this component.
|
|
588
|
+
* Used to replace the component when it is recycled.
|
|
589
|
+
* @return {string} The recycle placeholder.
|
|
590
|
+
* @private
|
|
406
591
|
*/
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
592
|
+
getRecycledMarker() {
|
|
593
|
+
return `<!--${Component.MARKER_RECYCLED(this.uid)}-->`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* Get the component nodes to be inserted into the DOM.
|
|
598
|
+
* Used internally during the render process, you usually don't need to call it if you use
|
|
599
|
+
* `mount()`.
|
|
600
|
+
* For components that render HTML elements you can safely rely on `this.el`.
|
|
601
|
+
* For container components that render another component you need the wrapper nodes to insert
|
|
602
|
+
* them into the DOM; use `getNodes()` for that.
|
|
603
|
+
* @return {Node[]} The component nodes.
|
|
604
|
+
*/
|
|
605
|
+
getNodes() {
|
|
606
|
+
return this.isContainer() ?
|
|
607
|
+
[this.template.interpolations[0].ref[0], ...this.children[0].getNodes(), this.template.interpolations[0].ref[1]] :
|
|
608
|
+
[this.el];
|
|
418
609
|
}
|
|
419
610
|
|
|
420
|
-
|
|
421
|
-
*
|
|
422
|
-
*
|
|
611
|
+
/**
|
|
612
|
+
* Used internally on the render process.
|
|
613
|
+
* Reuse a `Component` by replacing the placeholder comment with the real nodes.
|
|
614
|
+
* Call `onRecycle` lifecycle method.
|
|
615
|
+
* @param parent {node} The parent node.
|
|
616
|
+
* @return {Component} The component instance.
|
|
617
|
+
* @private
|
|
423
618
|
*/
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
this.
|
|
619
|
+
recycle(parent) {
|
|
620
|
+
// Locate the placeholder comment and replace it with the real nodes
|
|
621
|
+
const toBeReplaced = utils_findComment(parent, Component.MARKER_RECYCLED(this.uid), isComponent);
|
|
622
|
+
// Replace it with this.el.
|
|
623
|
+
toBeReplaced.replaceWith(...this.getNodes());
|
|
624
|
+
// Call `onRecycle` lifecycle method.
|
|
625
|
+
this.onRecycle.call(this);
|
|
428
626
|
// Return `this` for chaining.
|
|
429
627
|
return this;
|
|
430
628
|
}
|
|
@@ -450,13 +648,22 @@ class Component extends View {
|
|
|
450
648
|
}
|
|
451
649
|
|
|
452
650
|
/**
|
|
453
|
-
* Lifecycle method. Called when the
|
|
454
|
-
|
|
651
|
+
* Lifecycle method. Called when the component is rendered for the first time and hydrated.
|
|
652
|
+
*/
|
|
653
|
+
onHydrate() {}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Lifecycle method. Called when the component is recycled (reused with the same key) and added to the DOM again.
|
|
657
|
+
*/
|
|
658
|
+
onRecycle() {}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Lifecycle method. Called when the component is updated or re-rendered.
|
|
455
662
|
*/
|
|
456
|
-
|
|
663
|
+
onUpdate() {}
|
|
457
664
|
|
|
458
665
|
/**
|
|
459
|
-
* Lifecycle method. Called when the
|
|
666
|
+
* Lifecycle method. Called when the component is destroyed.
|
|
460
667
|
* @param {object} options Options object or any arguments passed to `destroy` method.
|
|
461
668
|
*/
|
|
462
669
|
onDestroy() {}
|
|
@@ -464,18 +671,18 @@ class Component extends View {
|
|
|
464
671
|
/**
|
|
465
672
|
* Tagged template helper method.
|
|
466
673
|
* Used to create a partial template.
|
|
467
|
-
* It will return a
|
|
674
|
+
* It will return a Partial object that preserves structure for position-based recycling.
|
|
468
675
|
* Components will be added as children by the parent component. Template strings literals
|
|
469
676
|
* will be marked as safe HTML to be rendered.
|
|
470
677
|
* This method is bound to the component instance by default.
|
|
471
678
|
* @param {TemplateStringsArray} strings - Template strings.
|
|
472
679
|
* @param {...any} expressions - Template expressions.
|
|
473
|
-
* @return {
|
|
680
|
+
* @return {Partial} Partial object containing strings and expressions.
|
|
474
681
|
* @example
|
|
475
682
|
* import { Component } from 'rasti';
|
|
476
683
|
* // Create a Title component.
|
|
477
684
|
* const Title = Component.create`
|
|
478
|
-
* <h1>${
|
|
685
|
+
* <h1>${({ props }) => props.children}</h1>
|
|
479
686
|
* `;
|
|
480
687
|
* // Create Main component.
|
|
481
688
|
* const Main = Component.create`
|
|
@@ -495,165 +702,213 @@ class Component extends View {
|
|
|
495
702
|
* });
|
|
496
703
|
*/
|
|
497
704
|
partial(strings, ...expressions) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
expandComponents(
|
|
501
|
-
|
|
502
|
-
|
|
705
|
+
const items = splitPlaceholders(
|
|
706
|
+
parsePartialElements(
|
|
707
|
+
expandComponents(
|
|
708
|
+
addPlaceholders(strings, expressions),
|
|
709
|
+
expressions
|
|
710
|
+
),
|
|
711
|
+
expressions
|
|
712
|
+
),
|
|
713
|
+
expressions
|
|
714
|
+
).map(item => getExpressionResult(item, this));
|
|
715
|
+
|
|
716
|
+
return new core_Partial(items);
|
|
503
717
|
}
|
|
504
718
|
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
719
|
+
/**
|
|
720
|
+
* Render a template part.
|
|
721
|
+
* @param {any} part - The template part.
|
|
722
|
+
* @param {function} addChild - The addChild function.
|
|
723
|
+
* @return {string} The rendered template part.
|
|
724
|
+
* @private
|
|
725
|
+
*/
|
|
726
|
+
renderTemplatePart(part, addChild) {
|
|
727
|
+
const result = getExpressionResult(part, this);
|
|
728
|
+
|
|
729
|
+
const parse = item => {
|
|
730
|
+
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
731
|
+
if (item instanceof core_SafeHTML) return item;
|
|
732
|
+
if (item instanceof Component) return addChild(item);
|
|
733
|
+
|
|
734
|
+
if (item instanceof core_Partial) {
|
|
735
|
+
this.pathManager.push();
|
|
736
|
+
const out = item.items.map(subItem => { this.pathManager.increment(); return parse(subItem); }).join('');
|
|
737
|
+
this.pathManager.pop();
|
|
738
|
+
return out;
|
|
739
|
+
}
|
|
740
|
+
// Handle arrays (user loops) - disable tracking.
|
|
741
|
+
if (Array.isArray(item)) {
|
|
742
|
+
this.pathManager.pause();
|
|
743
|
+
const out = utils_deepFlat(item).map(parse).join('');
|
|
744
|
+
this.pathManager.resume();
|
|
745
|
+
return out;
|
|
746
|
+
}
|
|
747
|
+
// InterpolationWrapper: add markers and process maintaining tracking.
|
|
748
|
+
if (item instanceof core_InterpolationWrapper) {
|
|
749
|
+
this.pathManager.increment();
|
|
750
|
+
const startMarker = `<!--${Component.MARKER_START(item.interpolationUid)}-->`;
|
|
751
|
+
const endMarker = `<!--${Component.MARKER_END(item.interpolationUid)}-->`;
|
|
752
|
+
return `${startMarker}${parse(item.result)}${endMarker}`;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
return Component.sanitize(item);
|
|
756
|
+
}
|
|
757
|
+
// Return empty string if item is undefined, null, false, or true.
|
|
758
|
+
return '';
|
|
759
|
+
};
|
|
510
760
|
|
|
511
|
-
return
|
|
512
|
-
`<${tag} ${attributes}></${tag}>` :
|
|
513
|
-
`<${tag} ${attributes} />`;
|
|
761
|
+
return `${parse(result)}`;
|
|
514
762
|
}
|
|
515
763
|
|
|
516
|
-
|
|
517
|
-
*
|
|
764
|
+
/**
|
|
765
|
+
* Render the component as a string.
|
|
766
|
+
* Used internally on the render process.
|
|
767
|
+
* Use it for server-side rendering or static site generation.
|
|
768
|
+
* @return {string} The rendered component.
|
|
518
769
|
*/
|
|
519
770
|
toString() {
|
|
520
771
|
// Normally there won't be any children, but if there are, destroy them.
|
|
521
772
|
this.destroyChildren();
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
//
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
773
|
+
// Normally there won't be any data event listeners, but if there are, clear them.
|
|
774
|
+
this.eventsManager.reset();
|
|
775
|
+
// Reset position tracking.
|
|
776
|
+
this.pathManager.reset();
|
|
777
|
+
// Bind addChild method.
|
|
778
|
+
const addChild = component => {
|
|
779
|
+
this.pathManager.track(component);
|
|
780
|
+
return this.addChild(component);
|
|
781
|
+
};
|
|
782
|
+
// Render the template parts.
|
|
783
|
+
return this.template.parts
|
|
784
|
+
.map(part => this.renderTemplatePart(part, addChild))
|
|
785
|
+
.join('');
|
|
534
786
|
}
|
|
535
787
|
|
|
536
|
-
|
|
537
|
-
*
|
|
788
|
+
/**
|
|
789
|
+
* Render the `Component`.
|
|
790
|
+
* - 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.
|
|
791
|
+
* - 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.
|
|
792
|
+
* - When rendering child components, recycling happens in two ways:
|
|
793
|
+
* - Components with a `key` are recycled if a previous child with the same key exists.
|
|
794
|
+
* - Unkeyed components are recycled if they have the same type and position in the template or partial.
|
|
795
|
+
* A recycled `Component` will call the `onRecycle` lifecycle method.
|
|
796
|
+
* - If the active element is inside the component, it will retain focus after the render.
|
|
797
|
+
* @return {Component} The component instance.
|
|
538
798
|
*/
|
|
539
799
|
render() {
|
|
540
800
|
// Prevent a last re render if view is already destroyed.
|
|
541
801
|
if (this.destroyed) return this;
|
|
542
|
-
|
|
543
|
-
if (!this.
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
fragment.innerHTML = this;
|
|
548
|
-
this.hydrate(fragment.content);
|
|
549
|
-
return this;
|
|
550
|
-
}
|
|
551
|
-
// Set `this.el` attributes.
|
|
552
|
-
const attributes = this.getAttributes();
|
|
553
|
-
// Remove attributes.
|
|
554
|
-
Object.keys(attributes.remove).forEach(key => {
|
|
555
|
-
this.el.removeAttribute(key);
|
|
556
|
-
});
|
|
557
|
-
// Add attributes.
|
|
558
|
-
Object.keys(attributes.add).forEach(key => {
|
|
559
|
-
this.el.setAttribute(key, attributes.add[key]);
|
|
560
|
-
});
|
|
802
|
+
// If `this.el` is not present, render the view as a string and hydrate it.
|
|
803
|
+
if (!this.el) {
|
|
804
|
+
const fragment = utils_parseHTML(this);
|
|
805
|
+
this.hydrate(fragment);
|
|
806
|
+
return this;
|
|
561
807
|
}
|
|
562
|
-
//
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
808
|
+
// Clear event listeners.
|
|
809
|
+
this.eventsManager.reset();
|
|
810
|
+
// Reset position tracking.
|
|
811
|
+
this.pathManager.reset();
|
|
812
|
+
// Store active element.
|
|
813
|
+
const activeElement = this.isContainer() ? null : document.activeElement;
|
|
814
|
+
// Update elements.
|
|
815
|
+
this.template.elements.forEach(element => element.update());
|
|
816
|
+
// Store previous children.
|
|
817
|
+
const previousChildren = this.children;
|
|
818
|
+
// Clear current children.
|
|
819
|
+
this.children = [];
|
|
820
|
+
// Update interpolations.
|
|
821
|
+
this.template.interpolations.forEach(interpolation => {
|
|
567
822
|
const nextChildren = [];
|
|
568
823
|
const recycledChildren = [];
|
|
569
824
|
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const inner = this.template.call(this, component => {
|
|
825
|
+
this.pathManager.increment();
|
|
826
|
+
|
|
827
|
+
const addChild = component => {
|
|
574
828
|
let out = component;
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
829
|
+
let found = null;
|
|
830
|
+
// Check if child already exists by key.
|
|
831
|
+
if (component.key) {
|
|
832
|
+
found = previousChildren.find(prev => prev.key === component.key);
|
|
833
|
+
} else {
|
|
834
|
+
// Find by position and type.
|
|
835
|
+
found = this.pathManager.findRecyclable(component.constructor);
|
|
836
|
+
}
|
|
579
837
|
|
|
580
838
|
if (found) {
|
|
581
839
|
// If child already exists, replace it html by its root element.
|
|
582
|
-
out = found.
|
|
840
|
+
out = found.getRecycledMarker();
|
|
583
841
|
// Add child to recycled children.
|
|
584
|
-
recycledChildren.push(found);
|
|
585
|
-
//
|
|
586
|
-
|
|
842
|
+
recycledChildren.push([found, component]);
|
|
843
|
+
// Track the component.
|
|
844
|
+
if (!found.key) this.pathManager.track(found);
|
|
587
845
|
} else {
|
|
588
|
-
//
|
|
846
|
+
// Add new component.
|
|
589
847
|
nextChildren.push(component);
|
|
848
|
+
// Track the component.
|
|
849
|
+
this.pathManager.track(component);
|
|
590
850
|
}
|
|
591
|
-
//
|
|
851
|
+
// Return the component or placeholder.
|
|
592
852
|
return out;
|
|
593
|
-
}
|
|
853
|
+
};
|
|
594
854
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// If `this.el` is present, replace it with nextEl.
|
|
604
|
-
if (this.el) this.el.replaceWith(nextEl);
|
|
605
|
-
// Set `this.el` to nextEl.
|
|
606
|
-
this.el = nextEl;
|
|
607
|
-
} else if (recycledChildren[0]) {
|
|
608
|
-
this.addChild(recycledChildren[0]);
|
|
609
|
-
} else {
|
|
610
|
-
throw new Error('Container component must have a child component');
|
|
611
|
-
}
|
|
612
|
-
} else {
|
|
613
|
-
this.el.innerHTML = inner;
|
|
614
|
-
// Add new children. Hydrate them.
|
|
615
|
-
nextChildren.forEach(nextChild => {
|
|
616
|
-
this.addChild(nextChild).hydrate(this.el);
|
|
617
|
-
});
|
|
618
|
-
// Replace children root elements with recycled components.
|
|
619
|
-
recycledChildren.forEach(recycledChild => {
|
|
620
|
-
this.addChild(recycledChild).recycle(this.el);
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
// Destroy unused children.
|
|
624
|
-
previousChildren.forEach(previousChild => {
|
|
625
|
-
const found = recycledChildren.indexOf(previousChild) > -1;
|
|
626
|
-
if (!found) previousChild.destroy();
|
|
855
|
+
const fragment = utils_parseHTML(this.renderTemplatePart(interpolation.expression, addChild));
|
|
856
|
+
// Replace children root elements with recycled components.
|
|
857
|
+
recycledChildren.forEach(([recycled, discarded]) => {
|
|
858
|
+
this.addChild(recycled).recycle(fragment);
|
|
859
|
+
// Update props.
|
|
860
|
+
recycled.props.set(discarded.props.toJSON());
|
|
861
|
+
// Destroy discarded component.
|
|
862
|
+
discarded.destroy();
|
|
627
863
|
});
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
864
|
+
// Add new children. Hydrate them.
|
|
865
|
+
nextChildren.forEach(child => {
|
|
866
|
+
this.addChild(child).hydrate(fragment);
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
interpolation.update(fragment);
|
|
870
|
+
});
|
|
871
|
+
// Destroy unused children.
|
|
872
|
+
previousChildren.forEach(prev => {
|
|
873
|
+
if (this.children.indexOf(prev) < 0) prev.destroy();
|
|
874
|
+
});
|
|
875
|
+
// If container, set el to the child element.
|
|
876
|
+
if (this.isContainer()) {
|
|
877
|
+
this.el = this.children[0].el;
|
|
878
|
+
} else {
|
|
879
|
+
// If there are pending event types, delegate events again.
|
|
880
|
+
if (this.eventsManager.hasPendingTypes()) {
|
|
881
|
+
this.delegateEvents();
|
|
631
882
|
}
|
|
632
883
|
}
|
|
633
|
-
//
|
|
634
|
-
this.
|
|
884
|
+
// Restore focus.
|
|
885
|
+
if (activeElement && this.el.contains(activeElement)) {
|
|
886
|
+
activeElement.focus();
|
|
887
|
+
}
|
|
888
|
+
// Call onUpdate lifecycle method.
|
|
889
|
+
this.onUpdate.call(this);
|
|
635
890
|
// Return this for chaining.
|
|
636
891
|
return this;
|
|
637
892
|
}
|
|
638
893
|
|
|
639
894
|
/**
|
|
640
|
-
* Mark a string as safe HTML to be rendered.
|
|
641
|
-
* Normally you don't need to use this method, as Rasti will automatically mark
|
|
642
|
-
* as safe HTML when the component is @link
|
|
643
|
-
* using the @link
|
|
895
|
+
* Mark a string as safe HTML to be rendered.
|
|
896
|
+
* Normally you don't need to use this method, as Rasti will automatically mark string literals
|
|
897
|
+
* as safe HTML when the component is {@link #module_component_create created} and when
|
|
898
|
+
* using the {@link #module_component__partial Component.partial} method.
|
|
644
899
|
* Be sure that the string is safe to be rendered, as it will be inserted into the DOM without any sanitization.
|
|
645
900
|
* @static
|
|
646
901
|
* @param {string} value
|
|
647
|
-
* @return {
|
|
902
|
+
* @return {SafeHTML} A safe HTML object.
|
|
648
903
|
*/
|
|
649
904
|
static markAsSafeHTML(value) {
|
|
650
|
-
return new
|
|
905
|
+
return new core_SafeHTML(value);
|
|
651
906
|
}
|
|
652
907
|
|
|
653
908
|
/**
|
|
654
909
|
* Helper method used to extend a `Component`, creating a subclass.
|
|
655
910
|
* @static
|
|
656
|
-
* @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.
|
|
911
|
+
* @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.
|
|
657
912
|
*/
|
|
658
913
|
static extend(object) {
|
|
659
914
|
const Current = this;
|
|
@@ -674,27 +929,28 @@ class Component extends View {
|
|
|
674
929
|
* appends its element into the DOM (if `el` is provided).
|
|
675
930
|
* And returns the view instance.
|
|
676
931
|
* @static
|
|
677
|
-
* @param {object} options The view options.
|
|
678
|
-
* @param {node} el Dom element to append the view element.
|
|
679
|
-
* @param {boolean} hydrate If true, the view will hydrate existing DOM.
|
|
680
|
-
* @return {
|
|
932
|
+
* @param {object} [options] The view options.
|
|
933
|
+
* @param {node} [el] Dom element to append the view element.
|
|
934
|
+
* @param {boolean} [hydrate] If true, the view will hydrate existing DOM.
|
|
935
|
+
* @return {Component} The component instance.
|
|
681
936
|
*/
|
|
682
937
|
static mount(options, el, hydrate) {
|
|
683
|
-
// Instantiate
|
|
684
|
-
const
|
|
938
|
+
// Instantiate component.
|
|
939
|
+
const component = new this(options);
|
|
685
940
|
// If `el` is passed, mount component.
|
|
686
941
|
if (el) {
|
|
687
942
|
if (hydrate) {
|
|
943
|
+
// Generate subcomponents.
|
|
944
|
+
component.toString();
|
|
688
945
|
// Hydrate existing DOM.
|
|
689
|
-
|
|
690
|
-
view.hydrate(el);
|
|
946
|
+
component.hydrate(el);
|
|
691
947
|
} else {
|
|
692
948
|
// Append element to the DOM.
|
|
693
|
-
el.
|
|
949
|
+
el.append(...component.render().getNodes());
|
|
694
950
|
}
|
|
695
951
|
}
|
|
696
|
-
// Return
|
|
697
|
-
return
|
|
952
|
+
// Return component instance.
|
|
953
|
+
return component;
|
|
698
954
|
}
|
|
699
955
|
|
|
700
956
|
/**
|
|
@@ -707,16 +963,45 @@ class Component extends View {
|
|
|
707
963
|
* - 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.
|
|
708
964
|
* ```javascript
|
|
709
965
|
* const Button = Component.create`
|
|
710
|
-
* <button class="${({
|
|
711
|
-
* ${({
|
|
966
|
+
* <button class="${({ props }) => props.className}">
|
|
967
|
+
* ${({ props }) => props.children}
|
|
968
|
+
* </button>
|
|
969
|
+
* `;
|
|
970
|
+
* ```
|
|
971
|
+
* - Attach DOM event handlers per element using camel-cased attributes.
|
|
972
|
+
* Event handlers are automatically bound to the component instance (`this`).
|
|
973
|
+
* Internally, Rasti uses event delegation to the component's root element for performance.
|
|
974
|
+
*
|
|
975
|
+
* **Attribute Quoting:**
|
|
976
|
+
* - **Quoted attributes** (`onClick="${handler}"`) evaluate the expression first, useful for dynamic values
|
|
977
|
+
* - **Unquoted attributes** (`onClick=${handler}`) pass the function reference directly
|
|
978
|
+
*
|
|
979
|
+
* **Listener Signature:** `(event, component, matched)`
|
|
980
|
+
* - `event`: The native DOM event object
|
|
981
|
+
* - `component`: The component instance (same as `this`)
|
|
982
|
+
* - `matched`: The element that matched the event (useful for delegation)
|
|
983
|
+
*
|
|
984
|
+
* ```javascript
|
|
985
|
+
* const Button = Component.create`
|
|
986
|
+
* <button
|
|
987
|
+
* onClick=${function(event, component, matched) {
|
|
988
|
+
* // this === component
|
|
989
|
+
* console.log('Button clicked:', matched);
|
|
990
|
+
* }}
|
|
991
|
+
* onMouseOver="${({ model }) => () => model.isHovered = true}"
|
|
992
|
+
* onMouseOut="${({ model }) => () => model.isHovered = false}"
|
|
993
|
+
* >
|
|
994
|
+
* Click me
|
|
712
995
|
* </button>
|
|
713
996
|
* `;
|
|
714
997
|
* ```
|
|
715
|
-
*
|
|
998
|
+
*
|
|
999
|
+
* If you need custom delegation (e.g., `{'click .selector': 'handler'}`),
|
|
1000
|
+
* you may override the `events` property as described in {@link #module_view__delegateevents View.delegateEvents}.
|
|
716
1001
|
* - Boolean attributes should be passed in the format `attribute="${() => true}"`. `false` attributes won't be rendered. `true` attributes will be rendered without a value.
|
|
717
1002
|
* ```javascript
|
|
718
1003
|
* const Input = Component.create`
|
|
719
|
-
* <input type="text" disabled=${({
|
|
1004
|
+
* <input type="text" disabled=${({ props }) => props.disabled} />
|
|
720
1005
|
* `;
|
|
721
1006
|
* ```
|
|
722
1007
|
* - If the interpolated function returns a component instance, it will be added as a child component.
|
|
@@ -725,21 +1010,21 @@ class Component extends View {
|
|
|
725
1010
|
* // Create a button component.
|
|
726
1011
|
* const Button = Component.create`
|
|
727
1012
|
* <button class="button">
|
|
728
|
-
* ${({
|
|
1013
|
+
* ${({ props }) => props.children}
|
|
729
1014
|
* </button>
|
|
730
1015
|
* `;
|
|
731
1016
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
732
1017
|
* const Navigation = Component.create`
|
|
733
1018
|
* <nav>
|
|
734
|
-
* ${({
|
|
735
|
-
* item => Button.mount({
|
|
1019
|
+
* ${({ props }) => props.items.map(
|
|
1020
|
+
* item => Button.mount({ children : item.label })
|
|
736
1021
|
* )}
|
|
737
1022
|
* </nav>
|
|
738
1023
|
* `;
|
|
739
1024
|
* // Create a header component. Add navigation as a child.
|
|
740
1025
|
* const Header = Component.create`
|
|
741
1026
|
* <header>
|
|
742
|
-
* ${({
|
|
1027
|
+
* ${({ props }) => Navigation.mount({ items : props.items})}
|
|
743
1028
|
* </header>
|
|
744
1029
|
* `;
|
|
745
1030
|
* ```
|
|
@@ -748,21 +1033,21 @@ class Component extends View {
|
|
|
748
1033
|
* // Create a button component.
|
|
749
1034
|
* const Button = Component.create`
|
|
750
1035
|
* <button class="button">
|
|
751
|
-
* ${({
|
|
1036
|
+
* ${({ props }) => props.children}
|
|
752
1037
|
* </button>
|
|
753
1038
|
* `;
|
|
754
1039
|
* // Create a navigation component. Add buttons as children. Iterate over items.
|
|
755
1040
|
* const Navigation = Component.create`
|
|
756
1041
|
* <nav>
|
|
757
|
-
* ${
|
|
758
|
-
* item =>
|
|
1042
|
+
* ${({ props, partial }) => props.items.map(
|
|
1043
|
+
* item => partial`<${Button}>${item.label}</${Button}>`
|
|
759
1044
|
* )}
|
|
760
1045
|
* </nav>
|
|
761
1046
|
* `;
|
|
762
1047
|
* // Create a header component. Add navigation as a child.
|
|
763
1048
|
* const Header = Component.create`
|
|
764
1049
|
* <header>
|
|
765
|
-
* <${Navigation} items="${({
|
|
1050
|
+
* <${Navigation} items="${({ props }) => props.items}" />
|
|
766
1051
|
* </header>
|
|
767
1052
|
* `;
|
|
768
1053
|
* ```
|
|
@@ -770,114 +1055,120 @@ class Component extends View {
|
|
|
770
1055
|
* ```javascript
|
|
771
1056
|
* // Create a button component.
|
|
772
1057
|
* const Button = Component.create`
|
|
773
|
-
* <button class="${({
|
|
774
|
-
* ${
|
|
1058
|
+
* <button class="${({ props }) => props.className}">
|
|
1059
|
+
* ${({ props }) => props.children}
|
|
775
1060
|
* </button>
|
|
776
1061
|
* `;
|
|
777
|
-
* // Create a container
|
|
1062
|
+
* // Create a container that renders a Button component.
|
|
778
1063
|
* const ButtonOk = Component.create`
|
|
779
1064
|
* <${Button} className="ok">Ok</${Button}>
|
|
780
1065
|
* `;
|
|
781
|
-
* // Create a
|
|
1066
|
+
* // Create a container that renders a Button component, using a function.
|
|
782
1067
|
* const ButtonCancel = Component.create(() => Button.mount({
|
|
783
|
-
* className: 'cancel',
|
|
784
|
-
*
|
|
1068
|
+
* className : 'cancel',
|
|
1069
|
+
* children : 'Cancel'
|
|
785
1070
|
* }));
|
|
786
1071
|
* ```
|
|
787
1072
|
* @static
|
|
788
|
-
* @param {string|
|
|
1073
|
+
* @param {string|Function} strings - HTML template for the component or a function that mounts a sub component.
|
|
789
1074
|
* @param {...*} expressions - The expressions to be interpolated within the template.
|
|
790
|
-
* @return {
|
|
1075
|
+
* @return {Component} The newly created component class.
|
|
791
1076
|
*/
|
|
792
1077
|
static create(strings, ...expressions) {
|
|
793
|
-
const PH = Component.PLACEHOLDER_EXPRESSION('(\\d+)');
|
|
794
1078
|
// Containers can be created using create as a functions instead of a tagged template.
|
|
795
1079
|
if (typeof strings === 'function') {
|
|
796
1080
|
expressions = [strings];
|
|
797
1081
|
strings = ['', ''];
|
|
798
1082
|
}
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
1083
|
+
// Create elements, interpolations and parts arrays.
|
|
1084
|
+
const elements = [], interpolations = [];
|
|
1085
|
+
const parts = splitPlaceholders(
|
|
1086
|
+
parseInterpolations(
|
|
1087
|
+
parseElements(
|
|
1088
|
+
expandComponents(
|
|
1089
|
+
addPlaceholders(
|
|
1090
|
+
strings,
|
|
1091
|
+
expressions
|
|
1092
|
+
).trim(),
|
|
1093
|
+
expressions
|
|
1094
|
+
),
|
|
1095
|
+
expressions,
|
|
1096
|
+
elements
|
|
1097
|
+
),
|
|
1098
|
+
expressions,
|
|
1099
|
+
interpolations
|
|
1100
|
+
),
|
|
1101
|
+
expressions
|
|
806
1102
|
);
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
return Object.keys(onlyEvents).reduce((out, key) => {
|
|
824
|
-
const typeListeners = getExpressionResult(onlyEvents[key], this);
|
|
825
|
-
|
|
826
|
-
Object.keys(typeListeners).forEach(selector => {
|
|
827
|
-
out[`${key}${selector === '&' ? '' : ` ${selector}`}`] = typeListeners[selector];
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
return out;
|
|
831
|
-
}, {});
|
|
832
|
-
};
|
|
833
|
-
// If there is a closing tag, get template.
|
|
834
|
-
if (close) {
|
|
835
|
-
const list = inner ? splitPlaceholders(inner, expressions) : [];
|
|
836
|
-
template = function(addChild) {
|
|
837
|
-
return utils_deepFlat(list.map(item => getExpressionResult(item, this))).map(item => {
|
|
838
|
-
if (typeof item !== 'undefined' && item !== null && item !== false && item !== true) {
|
|
839
|
-
if (item instanceof SafeHTML) return item;
|
|
840
|
-
if (item instanceof Component) return addChild(item);
|
|
841
|
-
return Component.sanitize(item);
|
|
842
|
-
}
|
|
843
|
-
return '';
|
|
844
|
-
}).join('');
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
} else {
|
|
848
|
-
// It's a container.
|
|
849
|
-
match = main.match(new RegExp(`^\\s*${PH}\\s*$`));
|
|
850
|
-
|
|
851
|
-
if (match) {
|
|
852
|
-
// If there is only one expression and no tag, is a container.
|
|
853
|
-
template = function(addChild) {
|
|
854
|
-
// Replace expressions.
|
|
855
|
-
return addChild(getExpressionResult(expressions[match[1]], this)).toString();
|
|
1103
|
+
// Create subclass for this component.
|
|
1104
|
+
return this.extend({
|
|
1105
|
+
template() {
|
|
1106
|
+
return {
|
|
1107
|
+
elements : elements.map(element => new core_Element({
|
|
1108
|
+
getSelector : element.getSelector.bind(this),
|
|
1109
|
+
getAttributes : element.getAttributes.bind(this)
|
|
1110
|
+
})),
|
|
1111
|
+
interpolations : interpolations.map(interpolation => new core_Interpolation({
|
|
1112
|
+
getStart : interpolation.getStart.bind(this),
|
|
1113
|
+
getEnd : interpolation.getEnd.bind(this),
|
|
1114
|
+
expression : interpolation.expression,
|
|
1115
|
+
isComponent,
|
|
1116
|
+
isElement
|
|
1117
|
+
})),
|
|
1118
|
+
parts,
|
|
856
1119
|
};
|
|
857
|
-
} else {
|
|
858
|
-
throw new SyntaxError('Invalid component');
|
|
859
1120
|
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const Current = this;
|
|
863
|
-
// Create subclass for this component.
|
|
864
|
-
return Current.extend({
|
|
865
|
-
// Set root element tag.
|
|
866
|
-
tag,
|
|
867
|
-
// Set attributes.
|
|
868
|
-
attributes,
|
|
869
|
-
// Set events.
|
|
870
|
-
events,
|
|
871
|
-
// Set template.
|
|
872
|
-
template
|
|
873
1121
|
});
|
|
874
1122
|
}
|
|
875
1123
|
}
|
|
876
1124
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
Component.
|
|
881
|
-
Component.
|
|
1125
|
+
/*
|
|
1126
|
+
* Attributes used to identify elements and events.
|
|
1127
|
+
*/
|
|
1128
|
+
Component.ATTRIBUTE_ELEMENT = 'data-rasti-el';
|
|
1129
|
+
Component.ATTRIBUTE_EVENT = (type) => `data-rasti-on-${type}`;
|
|
1130
|
+
|
|
1131
|
+
/*
|
|
1132
|
+
* Placeholders used to temporarily replace expressions in the template.
|
|
1133
|
+
*/
|
|
1134
|
+
Component.PLACEHOLDER = (idx) => `__RASTI-${idx}__`;
|
|
1135
|
+
|
|
1136
|
+
/*
|
|
1137
|
+
* Markers used to identify interpolation and recycled components.
|
|
1138
|
+
*/
|
|
1139
|
+
Component.MARKER_RECYCLED = (uid) => `rasti-recycled-${uid}`;
|
|
1140
|
+
Component.MARKER_START = (uid) => `rasti-start-${uid}`;
|
|
1141
|
+
Component.MARKER_END = (uid) => `rasti-end-${uid}`;
|
|
1142
|
+
|
|
1143
|
+
/**
|
|
1144
|
+
* Components are a special kind of `View` that is designed to be easily composable,
|
|
1145
|
+
* making it simple to add child views and build complex user interfaces.
|
|
1146
|
+
* Unlike views, which are render-agnostic, components have a specific set of rendering
|
|
1147
|
+
* guidelines that allow for a more declarative development style.
|
|
1148
|
+
* 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.
|
|
1149
|
+
* @module
|
|
1150
|
+
* @extends View
|
|
1151
|
+
* @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`.
|
|
1152
|
+
* @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.
|
|
1153
|
+
* @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.
|
|
1154
|
+
* @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.
|
|
1155
|
+
* @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.
|
|
1156
|
+
* @see {@link #module_component_create Component.create}
|
|
1157
|
+
* @example
|
|
1158
|
+
* import { Component, Model } from 'rasti';
|
|
1159
|
+
* // Create Timer component.
|
|
1160
|
+
* const Timer = Component.create`
|
|
1161
|
+
* <div>
|
|
1162
|
+
* Seconds: <span>${({ model }) => model.seconds}</span>
|
|
1163
|
+
* </div>
|
|
1164
|
+
* `;
|
|
1165
|
+
* // Create model to store seconds.
|
|
1166
|
+
* const model = new Model({ seconds: 0 });
|
|
1167
|
+
* // Mount timer on body.
|
|
1168
|
+
* Timer.mount({ model }, document.body);
|
|
1169
|
+
* // Increment `model.seconds` every second.
|
|
1170
|
+
* setInterval(() => model.seconds++, 1000);
|
|
1171
|
+
*/
|
|
1172
|
+
var Component$1 = Component.create`<div></div>`;
|
|
882
1173
|
|
|
883
|
-
module.exports = Component;
|
|
1174
|
+
module.exports = Component$1;
|