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