solarite 0.4.0 → 0.5.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/src/Path.js ADDED
@@ -0,0 +1,212 @@
1
+ import assert from "./assert.js";
2
+
3
+ /**
4
+ * Path to where an expression should be evaluated within a Shell or NodeGroup. */
5
+ export default class Path {
6
+
7
+ // Used for attributes:
8
+
9
+ /**
10
+ * @type {Node} Node that occurs before this Path's first Node.
11
+ * This is necessary because udomdiff() can steal nodes from another Path.
12
+ * If we had a pointer to our own startNode then that node could be moved somewhere else w/o us knowing it.
13
+ * Used only for type='content'
14
+ * Will be null if Path has no Nodes. */
15
+ nodeBefore;
16
+
17
+ /**
18
+ * If type is AttribType.Multiple or AttribType.Value, points to the node having the attribute.
19
+ * If type is 'content', points to a node that never changes that this NodeGroup should always insert its nodes before.
20
+ * An empty text node will be created to insertBefore if there's no other NodeMarker and this isn't at the last position.
21
+ * @type {Node|HTMLElement} */
22
+ nodeMarker;
23
+
24
+
25
+ // These are set after an expression is assigned:
26
+
27
+ /** @type {NodeGroup} */
28
+ parentNg;
29
+
30
+ /** @type {NodeGroup[]} */
31
+ nodeGroups = [];
32
+
33
+ // Caches to make things faster
34
+
35
+ /**
36
+ * @private
37
+ * @type {Node[]} Cached result of getNodes() */
38
+ nodesCache;
39
+
40
+ /**
41
+ * @type {int} Index of nodeBefore among its parentNode's children. */
42
+ nodeBeforeIndex;
43
+
44
+ /**
45
+ * @type {int[]} Path to the node marker, in reverse for performance reasons. */
46
+ nodeMarkerPath;
47
+
48
+ /** @type {?function} A function called by renderWatched() to update the value of this expression. */
49
+ watchFunction
50
+
51
+
52
+ /**
53
+ * @param nodeBefore {Node}
54
+ * @param nodeMarker {?Node}*/
55
+ constructor(nodeBefore, nodeMarker) {
56
+ this.nodeBefore = nodeBefore;
57
+ this.nodeMarker = nodeMarker;
58
+ /*#IFDEV*/this.verify();/*#ENDIF*/
59
+ }
60
+
61
+ /**
62
+ * Apply expressions to a path.
63
+ * This is called by NodeGroup.applyExprs() when it's time to put the expression values into the DOM.
64
+ *
65
+ * @param exprs {Expr[]}
66
+ * Suppose we have the following tagged template:
67
+ * `<div title=${expr1} class="big ${expr2} muted ${expr3}">
68
+ * ${expr4}
69
+ * <my-component></my-component>
70
+ * <my-component user=${expr5} roles="${expr6},${expr7}"></my-component>
71
+ * </div>`
72
+ * The exprs arrays will look like this, with each being passed to a path.
73
+ * [expr1] // title attribute value.
74
+ * [expr2, expr3] // class attribute values.
75
+ * [expr4] // children of div.
76
+ * [] // arguments to first my-component constructor.
77
+ * [[expr5], [expr6, expr7]] // arguments to second my-component constructor.
78
+ * [expr5] // user attribute value.
79
+ * [expr6, expr7] // role attribute value.
80
+ * @param freeNodeGroups {boolean} Used only by watch. */
81
+ apply(exprs, freeNodeGroups=true) {}
82
+
83
+ getExpressionCount() { return 1 }
84
+
85
+
86
+ /**
87
+ * Resolve nodeMarkerPath to new root.
88
+ * TODO: Make clone() use this.*/
89
+ getNewNodeMarker(newRoot, pathOffset) {
90
+ let root = newRoot;
91
+ let path = this.nodeMarkerPath;
92
+ let pathLength = path.length - pathOffset;
93
+ for (let i=pathLength-1; i>0; i--) { // Resolve the path.
94
+ //#IFDEV
95
+ assert(root.childNodes[path[i]]);
96
+ //#ENDIF
97
+ root = root.childNodes[path[i]];
98
+ }
99
+ let childNodes = root.childNodes;
100
+
101
+ return pathLength
102
+ ? childNodes[path[0]]
103
+ : newRoot;
104
+ }
105
+
106
+
107
+ /**
108
+ * @param newRoot {HTMLElement}
109
+ * @param pathOffset {int}
110
+ * @return {Path} */
111
+ clone(newRoot, pathOffset=0) {
112
+ /*#IFDEV*/this.verify();/*#ENDIF*/
113
+
114
+ // Resolve node paths.
115
+ let nodeMarker, nodeBefore;
116
+ let root = newRoot;
117
+ let path = this.nodeMarkerPath;
118
+ let pathLength = path.length - pathOffset;
119
+ for (let i=pathLength-1; i>0; i--) { // Resolve the path.
120
+ //#IFDEV
121
+ assert(root.childNodes[path[i]]);
122
+ //#ENDIF
123
+ root = root.childNodes[path[i]];
124
+ }
125
+ let childNodes = root.childNodes;
126
+
127
+ nodeMarker = pathLength
128
+ ? childNodes[path[0]]
129
+ : newRoot;
130
+ if (this.nodeBefore) {
131
+ //#IFDEV
132
+ assert(childNodes[this.nodeBeforeIndex]);
133
+ //#ENDIF
134
+ nodeBefore = childNodes[this.nodeBeforeIndex];
135
+
136
+ }
137
+
138
+ let result = new this.constructor(nodeBefore, nodeMarker, this.attrName, this.attrValue);
139
+
140
+ result.isComponentAttrib = this.isComponentAttrib;
141
+
142
+ // TODO: Put this in PathToAttribValue.clone().
143
+ result.isHtmlProperty = this.isHtmlProperty;
144
+
145
+ //#IFDEV
146
+ result.verify();
147
+ //#ENDIF
148
+
149
+ return result;
150
+ }
151
+
152
+ // Only used for watch.js
153
+ getNodes() {
154
+ return [this.nodeMarker];
155
+ }
156
+
157
+ /** @return {int[]} Returns indices in reverse order, because doing it that way is faster. */
158
+ static get(node) {
159
+ let result = [];
160
+ while(true) {
161
+ let parent = node.parentNode
162
+ if (!parent)
163
+ break;
164
+ result.push(Array.prototype.indexOf.call(node.parentNode.childNodes, node))
165
+ node = parent;
166
+ }
167
+ return result;
168
+ }
169
+
170
+ /**
171
+ * Note that the path is backward, with the outermost element at the end.
172
+ * @param root {HTMLElement|Document|DocumentFragment|ParentNode}
173
+ * @param path {int[]}
174
+ * @returns {Node|HTMLElement|HTMLStyleElement} */
175
+ static resolve(root, path) {
176
+ for (let i=path.length-1; i>=0; i--)
177
+ root = root.childNodes[path[i]];
178
+ return root;
179
+ }
180
+
181
+ //#IFDEV
182
+
183
+ /** @return {HTMLElement|ParentNode} */
184
+ getParentNode() {
185
+ return this.nodeMarker.parentNode
186
+ }
187
+
188
+ verify() {
189
+ if (!window.verify)
190
+ return;
191
+
192
+ // Need either nodeMarker or parentNode
193
+ assert(this.nodeMarker)
194
+
195
+ // nodeMarker must be attached.
196
+ assert(!this.nodeMarker || this.nodeMarker.parentNode)
197
+
198
+ assert(this.nodeBefore !== this.nodeMarker)
199
+
200
+ // Detect cyclic parent and grandparent references.
201
+ assert(this.parentNg?.parentPath !== this)
202
+ assert(this.parentNg?.parentPath?.parentNg?.parentPath !== this)
203
+ assert(this.parentNg?.parentPath?.parentNg?.parentPath?.parentNg?.parentPath !== this)
204
+
205
+ for (let ng of this.nodeGroups)
206
+ ng.verify();
207
+
208
+ // Make sure the nodesCache matches the nodes.
209
+ //this.checkNodesCache();
210
+ }
211
+ //#ENDIF
212
+ }
@@ -0,0 +1,259 @@
1
+ import Path from "./Path.js";
2
+ import Globals from "./Globals.js";
3
+ import Util from "./Util.js";
4
+ import delve from "./delve.js";
5
+ import assert from "./assert.js";
6
+
7
+ export default class PathToAttribValue extends Path {
8
+
9
+ /** @type {?string} Used only if type=AttribType.Value. */
10
+ attrName;
11
+
12
+ /**
13
+ * @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
14
+ attrValue;
15
+
16
+ isHtmlProperty;
17
+
18
+ constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
19
+ super(null, nodeMarker);
20
+ this.attrName = attrName;
21
+ this.attrValue = attrValue;
22
+ }
23
+
24
+ /**
25
+ * Set the value of an attribute. This can be for any attribute, not just attributes named "value".
26
+ * @param exprs {Expr[]} */
27
+ apply(exprs) {
28
+ //#IFDEV
29
+ assert(Array.isArray(exprs));
30
+ //#ENDIF
31
+
32
+ let node = this.nodeMarker;
33
+ let expr = exprs[0];
34
+
35
+ let multiple = this.attrValue;
36
+
37
+ // Two-way binding between attributes
38
+ // Passing a path to the value attribute.
39
+ // Copies the attribute to the property when the input event fires.
40
+ // value=${[this, 'value]'}
41
+ // checked=${[this, 'isAgree']}
42
+ // This same logic is in NodeGroup.instantiateComponent() for components.
43
+ if (!multiple && Util.isPath(expr)) {
44
+
45
+ // Don't bind events to component placeholders.
46
+ // PathToComponent will do the binding later when it instantiates the component.
47
+ if (this.isComponentAttrib && node.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
48
+ return;
49
+
50
+ /** @type {[Object, string[]]} */
51
+ let [obj, path] = [expr[0], expr.slice(1)];
52
+
53
+ if (!obj)
54
+ throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${[${expr.map(item => item ? `'${item}'` : item+'').join(', ')}]}>.`);
55
+
56
+ let value = delve(obj, path);
57
+
58
+ // Special case to allow setting select-multiple value from an array
59
+ if (this.attrName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
60
+ // Set the .selected property on the options having a value within value.
61
+ let strValues = value.map(v => v + '');
62
+ for (let option of node.options)
63
+ option.selected = strValues.includes(option.value)
64
+ }
65
+ else {
66
+ // TODO: should we remove isFalsy, since these are always props?
67
+ const strValue = Util.isFalsy(value) ? '' : value;
68
+
69
+ // Special case for contenteditable
70
+ if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
71
+ const existingValue = node.innerHTML;
72
+ if (strValue !== existingValue)
73
+ node.innerHTML = strValue;
74
+ }
75
+ else {
76
+
77
+ // If we don't have this condition, when we call render(), the browser will scroll to the currently
78
+ // selected item in a <select> and mess up manually scrolling to a different value.
79
+ if (strValue !== node[this.attrName])
80
+ node[this.attrName] = strValue;
81
+ }
82
+ }
83
+
84
+ // TODO: We need to remove any old listeners, like in bindEventAttribute.
85
+ // Does bindEvent() now handle that?
86
+ let func = () => {
87
+ let value = (this.attrName === 'value')
88
+ ? Util.getInputValue(node)
89
+ : node[this.attrName];
90
+ delve(obj, path, value);
91
+ }
92
+
93
+ // We use capture so we update the values before other events added by the user.
94
+ // TODO: Bind to scroll events also?
95
+ // What about resize events and width/height?
96
+ this.bindEvent(node, this.parentNg.getRootNode(), this.attrName, 'input', func, [], true);
97
+ }
98
+
99
+ // Regular attribute
100
+ else {
101
+ // Cache this on Path.isHtmlProperty when Shell creates the props.
102
+ // Have Path.clone() copy .isHtmlProperty?
103
+ let isProp = this.isHtmlProperty;
104
+
105
+ // Values to toggle an attribute
106
+ if (!multiple) {
107
+ Globals.currentPath = this; // Used by watch()
108
+ if (typeof expr === 'function') {
109
+ if (this.isComponentAttrib)
110
+ return;
111
+
112
+ this.watchFunction = expr; // The function that gets the expression, used for renderWatched()
113
+ expr = expr();
114
+ }
115
+ else
116
+ expr = Util.makePrimitive(expr);
117
+ Globals.currentPath = null;
118
+ }
119
+
120
+
121
+ if (!multiple && (expr === undefined || expr === false || expr === null)) { // Util.isFalsy() inlined.
122
+ if (isProp)
123
+ node[this.attrName] = false;
124
+ node.removeAttribute(this.attrName);
125
+ }
126
+ else if (!multiple && expr === true) {
127
+ if (isProp)
128
+ node[this.attrName] = true;
129
+ node.setAttribute(this.attrName, '');
130
+ }
131
+
132
+ // A non-toggled attribute
133
+ else {
134
+
135
+ // If it's a series of expressions among strings, join them together.
136
+ let joinedValue = multiple // avoid function call if there are no strings
137
+ ? this.getValue(exprs)
138
+ : expr // If the attribute is one expression with no strings
139
+
140
+ // Only update attributes if the value has changed.
141
+ // This is needed for setting input.value, .checked, option.selected, etc.
142
+ let oldVal = isProp
143
+ ? node[this.attrName]
144
+ : node.getAttribute(this.attrName);
145
+ if (oldVal !== joinedValue) {
146
+
147
+ // <textarea value=${expr}></textarea>
148
+ // Without this branch we have no way to set the value of a textarea,
149
+ // since we also prohibit expressions that are a child of textarea.
150
+ if (isProp)
151
+ node[this.attrName] = joinedValue;
152
+
153
+ // Allow one-way binding to contenteditable value attribute.
154
+ // Contenteditables normally don't have a value attribute and have their content set via innerHTML.
155
+ // Solarite doesn't allow contenteditables to have expressions as their children.
156
+ else if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
157
+ node.innerHTML = joinedValue;
158
+ }
159
+
160
+ // TODO: Putting an 'else' here would be more performant
161
+ node.setAttribute(this.attrName, joinedValue);
162
+ }
163
+ }
164
+ }
165
+ }
166
+
167
+
168
+ getExpressionCount() { return this.attrValue ? this.attrValue.length-1 : 1 }
169
+
170
+ /**
171
+ * @param exprs {Expr|Expr[]} // TODO: Why is this sometimes not an array?
172
+ * @return {string} The joined values of the expressions, or the first expression if there are no strings. */
173
+ getValue(exprs) {
174
+
175
+ //#IFDEV
176
+ assert(Array.isArray(exprs));
177
+ //#ENDIF
178
+ //if (!Array.isArray(exprs))
179
+ // return exprs;
180
+
181
+ if (!this.attrValue) {// If it's not multiple paths inside a single attribute, return first (and only) expression.
182
+ //#IFDEV
183
+ assert(exprs.length === 1);
184
+ //#ENDIF
185
+ return exprs[0];
186
+ }
187
+
188
+ let result = [];
189
+ let values = this.attrValue;
190
+ for (let i = 0; i < values.length; i++) {
191
+ result.push(values[i]);
192
+ if (i < values.length - 1) {
193
+ Globals.currentPath = this; // Used by watch()
194
+ let val = Util.makePrimitive(exprs[i]);
195
+ Globals.currentPath = null;
196
+ if (!Util.isFalsy(val))
197
+ result.push(val);
198
+ }
199
+ }
200
+ return result.join('')
201
+ }
202
+
203
+ /**
204
+ * Call function when eventName is triggerd on node.
205
+ * @param node {HTMLElement}
206
+ * @param root {HTMLElement}
207
+ * @param key {string}
208
+ * @param eventName {string}
209
+ * @param func {function}
210
+ * @param args {array}
211
+ * @param capture {boolean} */
212
+ bindEvent(node, root, key, eventName, func, args, capture=false) {
213
+ let nodeEvents = Globals.nodeEvents.get(node);
214
+ if (!nodeEvents) {
215
+ nodeEvents = {[key]: new Array(3)};
216
+ Globals.nodeEvents.set(node, nodeEvents);
217
+ }
218
+ let nodeEvent = nodeEvents[key];
219
+ if (!nodeEvent)
220
+ nodeEvents[key] = nodeEvent = new Array(3);
221
+
222
+ if (typeof func !== 'function')
223
+ throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${${func}}> because it's not a function.`);
224
+
225
+ // If function has changed, remove and rebind the event.
226
+ if (nodeEvent[0] !== func) {
227
+
228
+ // TODO: We should be removing event listeners when calling getNodeGroup(),
229
+ // when we get the node from the list of nodeGroupsAttached/nodeGroupsDetached,
230
+ // instead of only when we rebind an event.
231
+ let [existing, existingBound, _] = nodeEvent;
232
+ if (existing)
233
+ node.removeEventListener(eventName, existingBound, capture);
234
+
235
+ let originalFunc = func;
236
+
237
+ // BoundFunc sets the "this" variable to be the current Solarite component.
238
+ let boundFunc = (event) => {
239
+ let args = nodeEvent[2];
240
+ return originalFunc.call(root, ...args, event, node);
241
+ }
242
+
243
+ // Save both the original and bound functions.
244
+ // Original so we can compare it against a newly assigned function.
245
+ // Bound so we can use it with removeEventListner().
246
+ nodeEvent[0] = originalFunc;
247
+ nodeEvent[1] = boundFunc;
248
+
249
+ node.addEventListener(eventName, boundFunc, capture);
250
+
251
+ // TODO: classic event attribs?
252
+ //el[attr.name] = e => // e.g. el.onclick = ... // put "event", "el", and "this" in scope for the event code.
253
+ // (new Function('event', 'el', attr.value)).bind(this.manager.rootEl)(e, el)
254
+ }
255
+
256
+ // Otherwise just update the args to the function.
257
+ nodeEvents[key][2] = args;
258
+ }
259
+ }
@@ -0,0 +1,77 @@
1
+ import Path from "./Path.js";
2
+ import Globals from "./Globals.js";
3
+ import assert from "./assert.js";
4
+
5
+ export default class PathToAttribs extends Path {
6
+
7
+ /**
8
+ * @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
9
+ attrNames;
10
+
11
+ constructor(nodeBefore, nodeMarker) {
12
+ super(null, null);
13
+ this.nodeMarker = nodeMarker;
14
+ this.attrNames = new Set();
15
+ }
16
+
17
+ /**
18
+ * @param exprs {Expr[][]} Only the first is used.
19
+ * @param freeNodeGroups {boolean} Used only for watch. */
20
+ apply(exprs, freeNodeGroups) {
21
+ //#IFDEV
22
+ assert(Array.isArray(exprs));
23
+ //#ENDIF
24
+
25
+ let expr = exprs[0];
26
+ let node = this.nodeMarker;
27
+
28
+ if (Array.isArray(expr))
29
+ expr = expr.flat().join(' '); // flat and join so we can accept arrays of arrays of strings.
30
+
31
+ // Add new attributes
32
+ let oldNames = this.attrNames;
33
+ this.attrNames = new Set();
34
+ if (expr) {
35
+ if (typeof expr === 'function') {
36
+ Globals.currentPath = this; // Used by watch()
37
+ this.watchFunction = expr; // used by renderWatched()
38
+ expr = expr();
39
+ Globals.currentPath = null;
40
+ }
41
+
42
+ // Attribute as name: value object.
43
+ if (typeof expr === 'object') {
44
+ for (let name in expr) {
45
+ let value = expr[name];
46
+ if (value === undefined || value === false || value === null)
47
+ continue;
48
+ node.setAttribute(name, value);
49
+ this.attrNames.add(name)
50
+ }
51
+ }
52
+
53
+ // Attributes as string
54
+ else {
55
+ let attrs = (expr + '') // Split string into multiple attributes.
56
+ .split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|\S+))/g)
57
+ .map(text => text.trim())
58
+ .filter(text => text.length);
59
+
60
+ for (let attr of attrs) {
61
+ let [name, value] = attr.split(/\s*=\s*/); // split on first equals.
62
+ value = (value || '').replace(/^(['"])(.*)\1$/, '$2'); // trim value quotes if they match.
63
+ node.setAttribute(name, value);
64
+ this.attrNames.add(name)
65
+ }
66
+ }
67
+ }
68
+
69
+ // Remove old attributes.
70
+ for (let oldName of oldNames)
71
+ if (!this.attrNames.has(oldName))
72
+ node.removeAttribute(oldName);
73
+ }
74
+
75
+
76
+ getExpressionCount() { return 1 }
77
+ }
@@ -0,0 +1,8 @@
1
+ import Path from "./Path.js";
2
+
3
+ // This Path renders nothing.
4
+ export default class PathToComment extends Path {
5
+ constructor(nodeBefore, nodeMarker) {
6
+ super(nodeBefore, nodeMarker);
7
+ }
8
+ }