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