solarite 0.7.0 → 0.8.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/dist/Solarite-debug.js +5193 -4114
- package/dist/Solarite.js +4951 -3878
- package/dist/Solarite.min.js +2 -2
- package/dist/jsx-dev-runtime.js +3 -0
- package/dist/jsx-runtime.js +86 -0
- package/package.json +11 -10
- package/readme.md +8 -8
- package/src/MappedList.js +34 -0
- package/src/MultiValueMap.js +4 -4
- package/src/NodeGroup.js +205 -119
- package/src/Path.js +52 -53
- package/src/PathToAttribValue.js +182 -101
- package/src/PathToAttribs.js +10 -18
- package/src/PathToComponent.js +43 -19
- package/src/PathToEvent.js +18 -12
- package/src/PathToKey.js +0 -6
- package/src/PathToNodes.js +695 -155
- package/src/RootNodeGroup.js +23 -30
- package/src/Selector.js +207 -0
- package/src/Shell.js +276 -117
- package/src/Solarite.d.ts +89 -13
- package/src/Solarite.js +38 -25
- package/src/Template.js +20 -7
- package/src/Util.js +75 -60
- package/src/assert.js +1 -1
- package/src/assignAttributes.js +20 -19
- package/src/h.js +64 -37
- package/src/jsx.js +4 -7
- package/src/toEl.js +1 -1
- package/src/HtmlParser.js +0 -91
- package/src/PathToComment.js +0 -8
package/src/Path.js
CHANGED
|
@@ -39,6 +39,23 @@ export default class Path {
|
|
|
39
39
|
* @type {Node[]} Cached result of getNodes() */
|
|
40
40
|
nodesCache;
|
|
41
41
|
|
|
42
|
+
/** @type {boolean|undefined} True when this path provides an attribute of a web component
|
|
43
|
+
* (a -solarite-placeholder element). Only attribute paths ever set it true, but it's
|
|
44
|
+
* declared here on every Path because clone() and cloneWithNodes() copy it to every clone;
|
|
45
|
+
* declaring it keeps those stores from transitioning the clone's hidden class. */
|
|
46
|
+
isComponentAttrib;
|
|
47
|
+
|
|
48
|
+
/** @type {boolean} True when re-applying an expression identical to the one already
|
|
49
|
+
* applied is provably a no-op, so a re-render can skip this path entirely. Only event
|
|
50
|
+
* bindings qualify: binding the same handler to the same node again changes nothing,
|
|
51
|
+
* while an attribute or a child expression may have been altered outside the template. */
|
|
52
|
+
skipIfSame = false;
|
|
53
|
+
|
|
54
|
+
/** @type {boolean|undefined} True when the attribute is a live HTML property
|
|
55
|
+
* (checked/value/selected — Util.isHtmlProp), which users can flip underneath the
|
|
56
|
+
* template. Declared here for the same hidden-class reason as isComponentAttrib. */
|
|
57
|
+
isHtmlProperty;
|
|
58
|
+
|
|
42
59
|
// Set only on Shell paths, never on cloned instances, so they're not declared as
|
|
43
60
|
// class fields; that would cost a store per field on every clone:
|
|
44
61
|
// nodeBeforeIndex {int} Index of nodeBefore among its parentNode's children.
|
|
@@ -52,7 +69,7 @@ export default class Path {
|
|
|
52
69
|
constructor(nodeBefore, nodeMarker) {
|
|
53
70
|
this.nodeBefore = nodeBefore;
|
|
54
71
|
this.nodeMarker = nodeMarker;
|
|
55
|
-
/*#
|
|
72
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
56
73
|
}
|
|
57
74
|
|
|
58
75
|
/**
|
|
@@ -74,7 +91,12 @@ export default class Path {
|
|
|
74
91
|
* [[expr5], [expr6, expr7]] // arguments to second my-component constructor.
|
|
75
92
|
* [expr5] // user attribute value.
|
|
76
93
|
* [expr6, expr7] // role attribute value. */
|
|
77
|
-
|
|
94
|
+
applyAll(exprs) {
|
|
95
|
+
//#IFDEBUG
|
|
96
|
+
assert(Array.isArray(exprs));
|
|
97
|
+
//#ENDIF
|
|
98
|
+
this.applySingle(exprs[0]);
|
|
99
|
+
}
|
|
78
100
|
|
|
79
101
|
/**
|
|
80
102
|
* Fast path used by NodeGroup.applyExprs() when every path consumes exactly one expression.
|
|
@@ -84,26 +106,12 @@ export default class Path {
|
|
|
84
106
|
|
|
85
107
|
getExpressionCount() { return 1 }
|
|
86
108
|
|
|
87
|
-
|
|
88
109
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
let pathLength = path.length - pathOffset;
|
|
95
|
-
for (let i=pathLength-1; i>0; i--) { // Resolve the path.
|
|
96
|
-
//#IFDEV
|
|
97
|
-
assert(root.childNodes[path[i]]);
|
|
98
|
-
//#ENDIF
|
|
99
|
-
root = root.childNodes[path[i]];
|
|
100
|
-
}
|
|
101
|
-
let childNodes = root.childNodes;
|
|
102
|
-
|
|
103
|
-
return pathLength
|
|
104
|
-
? childNodes[path[0]]
|
|
105
|
-
: newRoot;
|
|
106
|
-
}
|
|
110
|
+
* The value a path hands to a component constructor, for the single-expression paths.
|
|
111
|
+
* PathToAttribValue overrides this to join its surrounding static strings.
|
|
112
|
+
* @param exprs {Expr[]}
|
|
113
|
+
* @return {Expr} */
|
|
114
|
+
getValue(exprs) { return exprs[0] }
|
|
107
115
|
|
|
108
116
|
|
|
109
117
|
/**
|
|
@@ -113,7 +121,7 @@ export default class Path {
|
|
|
113
121
|
* @param nodeMarker {Node}
|
|
114
122
|
* @return {Path} */
|
|
115
123
|
cloneWithNodes(nodeBefore, nodeMarker) {
|
|
116
|
-
let result = new this.constructor(nodeBefore, nodeMarker, this.
|
|
124
|
+
let result = new this.constructor(nodeBefore, nodeMarker, this.attribName, this.attrValue);
|
|
117
125
|
result.isComponentAttrib = this.isComponentAttrib;
|
|
118
126
|
result.wholeParent = this.wholeParent;
|
|
119
127
|
result.isHtmlProperty = this.isHtmlProperty;
|
|
@@ -125,41 +133,25 @@ export default class Path {
|
|
|
125
133
|
* @param pathOffset {int}
|
|
126
134
|
* @return {Path} */
|
|
127
135
|
clone(newRoot, pathOffset=0) {
|
|
128
|
-
/*#
|
|
129
|
-
|
|
130
|
-
// Resolve node paths.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
let
|
|
135
|
-
|
|
136
|
-
//#IFDEV
|
|
137
|
-
assert(root.childNodes[path[i]]);
|
|
138
|
-
//#ENDIF
|
|
139
|
-
root = root.childNodes[path[i]];
|
|
140
|
-
}
|
|
141
|
-
let childNodes = root.childNodes;
|
|
142
|
-
|
|
143
|
-
nodeMarker = pathLength
|
|
144
|
-
? childNodes[path[0]]
|
|
145
|
-
: newRoot;
|
|
136
|
+
/*#IFDEBUG*/this.verify();/*#ENDIF*/
|
|
137
|
+
|
|
138
|
+
// Resolve node paths. nodeBefore is always a sibling of nodeMarker (Shell builds it from
|
|
139
|
+
// nodeMarker.previousSibling, or inserts a comment immediately before it), so the list
|
|
140
|
+
// nodeBeforeIndex counts within is the marker's own parent's childNodes. An empty path
|
|
141
|
+
// leaves the marker as newRoot itself, and then that list is newRoot's children.
|
|
142
|
+
let nodeBefore;
|
|
143
|
+
let nodeMarker = Path.resolve(newRoot, this.nodeMarkerPath, pathOffset);
|
|
146
144
|
if (this.nodeBefore) {
|
|
147
|
-
|
|
145
|
+
let childNodes = (nodeMarker === newRoot ? newRoot : nodeMarker.parentNode).childNodes;
|
|
146
|
+
//#IFDEBUG
|
|
148
147
|
assert(childNodes[this.nodeBeforeIndex]);
|
|
149
148
|
//#ENDIF
|
|
150
149
|
nodeBefore = childNodes[this.nodeBeforeIndex];
|
|
151
|
-
|
|
152
150
|
}
|
|
153
151
|
|
|
154
|
-
let result =
|
|
155
|
-
|
|
156
|
-
result.isComponentAttrib = this.isComponentAttrib;
|
|
157
|
-
result.wholeParent = this.wholeParent;
|
|
158
|
-
|
|
159
|
-
// TODO: Put this in PathToAttribValue.clone().
|
|
160
|
-
result.isHtmlProperty = this.isHtmlProperty;
|
|
152
|
+
let result = this.cloneWithNodes(nodeBefore, nodeMarker);
|
|
161
153
|
|
|
162
|
-
//#
|
|
154
|
+
//#IFDEBUG
|
|
163
155
|
result.verify();
|
|
164
156
|
//#ENDIF
|
|
165
157
|
|
|
@@ -183,14 +175,21 @@ export default class Path {
|
|
|
183
175
|
* Note that the path is backward, with the outermost element at the end.
|
|
184
176
|
* @param root {HTMLElement|Document|DocumentFragment|ParentNode}
|
|
185
177
|
* @param path {int[]}
|
|
178
|
+
* @param skip {int} How many of the outermost steps to leave off, for when root is
|
|
179
|
+
* already that many levels down from where the path was recorded. An empty walk
|
|
180
|
+
* (skip === path.length) returns root itself.
|
|
186
181
|
* @returns {Node|HTMLElement|HTMLStyleElement} */
|
|
187
|
-
static resolve(root, path) {
|
|
188
|
-
for (let i=path.length-1; i>=0; i--)
|
|
182
|
+
static resolve(root, path, skip=0) {
|
|
183
|
+
for (let i=path.length-1-skip; i>=0; i--) {
|
|
184
|
+
//#IFDEBUG
|
|
185
|
+
assert(root.childNodes[path[i]]);
|
|
186
|
+
//#ENDIF
|
|
189
187
|
root = root.childNodes[path[i]];
|
|
188
|
+
}
|
|
190
189
|
return root;
|
|
191
190
|
}
|
|
192
191
|
|
|
193
|
-
//#
|
|
192
|
+
//#IFDEBUG
|
|
194
193
|
|
|
195
194
|
/** @return {HTMLElement|ParentNode} */
|
|
196
195
|
getParentNode() {
|
package/src/PathToAttribValue.js
CHANGED
|
@@ -2,32 +2,30 @@ import Path from "./Path.js";
|
|
|
2
2
|
import Util from "./Util.js";
|
|
3
3
|
import delve, {isDelvePath} from "./delve.js";
|
|
4
4
|
import assert from "./assert.js";
|
|
5
|
+
import {SelectorRef} from "./Selector.js";
|
|
5
6
|
|
|
6
7
|
export default class PathToAttribValue extends Path {
|
|
7
8
|
|
|
8
9
|
/** @type {?string} Used only if type=AttribType.Value. */
|
|
9
|
-
|
|
10
|
+
attribName;
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
|
|
13
14
|
attrValue;
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
isComponent;
|
|
16
|
+
// isComponentAttrib and isHtmlProperty are declared on the Path base class.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
|
|
18
|
+
constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
|
|
21
19
|
super(null, nodeMarker);
|
|
22
|
-
this.
|
|
20
|
+
this.attribName = attribName;
|
|
23
21
|
this.attrValue = attrValue;
|
|
24
22
|
}
|
|
25
23
|
|
|
26
24
|
/**
|
|
27
25
|
* Set the value of an attribute. This can be for any attribute, not just attributes named "value".
|
|
28
26
|
* @param exprs {Expr[]} */
|
|
29
|
-
|
|
30
|
-
//#
|
|
27
|
+
applyAll(exprs) {
|
|
28
|
+
//#IFDEBUG
|
|
31
29
|
assert(Array.isArray(exprs));
|
|
32
30
|
//#ENDIF
|
|
33
31
|
|
|
@@ -40,14 +38,14 @@ export default class PathToAttribValue extends Path {
|
|
|
40
38
|
// Only update attributes if the value has changed.
|
|
41
39
|
// This is needed for setting input.value, .checked, option.selected, etc.
|
|
42
40
|
let oldVal = isProp
|
|
43
|
-
? node[this.
|
|
44
|
-
: node.getAttribute(this.
|
|
41
|
+
? node[this.attribName]
|
|
42
|
+
: node.getAttribute(this.attribName);
|
|
45
43
|
if (oldVal !== joinedValue) {
|
|
46
44
|
if (isProp)
|
|
47
|
-
node[this.
|
|
48
|
-
else if (this.
|
|
45
|
+
node[this.attribName] = joinedValue;
|
|
46
|
+
else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
|
|
49
47
|
node.innerHTML = joinedValue;
|
|
50
|
-
node.setAttribute(this.
|
|
48
|
+
node.setAttribute(this.attribName, joinedValue);
|
|
51
49
|
}
|
|
52
50
|
}
|
|
53
51
|
else
|
|
@@ -60,7 +58,7 @@ export default class PathToAttribValue extends Path {
|
|
|
60
58
|
applySingle(expr) {
|
|
61
59
|
// One expression surrounded by strings, e.g. class="a ${b} c". Join through apply().
|
|
62
60
|
if (this.attrValue)
|
|
63
|
-
return this.
|
|
61
|
+
return this.applyAll([expr]);
|
|
64
62
|
|
|
65
63
|
let node = this.nodeMarker;
|
|
66
64
|
|
|
@@ -81,12 +79,12 @@ export default class PathToAttribValue extends Path {
|
|
|
81
79
|
let [obj, path] = [expr[0], expr.slice(1)];
|
|
82
80
|
|
|
83
81
|
if (!obj)
|
|
84
|
-
throw new Error(`Solarite cannot bind
|
|
82
|
+
throw new Error(`Solarite cannot bind ${this.attribName} to ${obj}.`);
|
|
85
83
|
|
|
86
84
|
let value = delve(obj, path);
|
|
87
85
|
|
|
88
86
|
// Special case to allow setting select-multiple value from an array
|
|
89
|
-
if (this.
|
|
87
|
+
if (this.attribName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
|
|
90
88
|
// Set the .selected property on the options having a value within value.
|
|
91
89
|
let strValues = value.map(v => v + '');
|
|
92
90
|
for (let option of node.options)
|
|
@@ -102,7 +100,7 @@ export default class PathToAttribValue extends Path {
|
|
|
102
100
|
const strValue = Util.isFalsy(value) ? '' : value;
|
|
103
101
|
|
|
104
102
|
// Special case for contenteditable
|
|
105
|
-
if (this.
|
|
103
|
+
if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
|
|
106
104
|
const existingValue = node.innerHTML;
|
|
107
105
|
if (strValue !== existingValue)
|
|
108
106
|
node.innerHTML = strValue;
|
|
@@ -111,28 +109,39 @@ export default class PathToAttribValue extends Path {
|
|
|
111
109
|
|
|
112
110
|
// If we don't have this condition, when we call render(), the browser will scroll to the currently
|
|
113
111
|
// selected item in a <select> and mess up manually scrolling to a different value.
|
|
114
|
-
if (strValue !== node[this.
|
|
115
|
-
node[this.
|
|
112
|
+
if (strValue !== node[this.attribName])
|
|
113
|
+
node[this.attribName] = strValue;
|
|
116
114
|
}
|
|
117
115
|
}
|
|
118
116
|
|
|
119
117
|
// TODO: We need to remove any old listeners, like in bindEventAttribute.
|
|
120
118
|
// Does bindEvent() now handle that?
|
|
121
119
|
let func = () => {
|
|
122
|
-
let value = (this.
|
|
120
|
+
let value = (this.attribName === 'value' || node.type === 'radio')
|
|
123
121
|
? Util.getInputValue(node)
|
|
124
|
-
: node[this.
|
|
122
|
+
: node[this.attribName];
|
|
125
123
|
delve(obj, path, value);
|
|
126
124
|
}
|
|
127
125
|
|
|
128
126
|
// We use capture so we update the values before other events added by the user.
|
|
129
127
|
// TODO: Bind to scroll events also?
|
|
130
128
|
// What about resize events and width/height?
|
|
131
|
-
this.bindEvent(node, this.parentNg.
|
|
129
|
+
this.bindEvent(node, this.parentNg.getRootEl(), this.attribName, 'input', func, null, true);
|
|
132
130
|
}
|
|
133
131
|
|
|
134
132
|
// Regular attribute
|
|
135
133
|
else {
|
|
134
|
+
// A selection binding (h.selector().when()) writes its own value and tells the
|
|
135
|
+
// selector which list this row belongs to, so a later change of selection reaches
|
|
136
|
+
// the attribute directly instead of going back through render(). The typeof test
|
|
137
|
+
// keeps ordinary string attributes — nearly all of them — from paying for the
|
|
138
|
+
// prototype check.
|
|
139
|
+
if (typeof expr === 'object' && expr instanceof SelectorRef) {
|
|
140
|
+
if (!this.isComponentAttrib)
|
|
141
|
+
expr.bind(node, this.attribName, this.parentNg);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
// Cache this on Path.isHtmlProperty when Shell creates the props.
|
|
137
146
|
// Have Path.clone() copy .isHtmlProperty?
|
|
138
147
|
let isProp = this.isHtmlProperty;
|
|
@@ -145,43 +154,53 @@ export default class PathToAttribValue extends Path {
|
|
|
145
154
|
else
|
|
146
155
|
expr = Util.makePrimitive(expr);
|
|
147
156
|
|
|
148
|
-
// Values
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
// Values that remove an attribute. The empty string is included so that an attribute
|
|
158
|
+
// disappears whenever its expression is empty, instead of only when it happened to be
|
|
159
|
+
// absent already. makePrimitive() above turns null into '', so plain null lands here
|
|
160
|
+
// too; the explicit null test still matters for a function expression returning null,
|
|
161
|
+
// which skips makePrimitive.
|
|
162
|
+
// An html property is exempt: on those, '' is a real value meaning "empty", as when
|
|
163
|
+
// clearing an <input>, so it belongs on the assignment path below.
|
|
164
|
+
if (expr === undefined || expr === false || expr === null || (expr === '' && !isProp)) {
|
|
165
|
+
if (isProp) {
|
|
166
|
+
// Clear the property with a value of its own type. Assigning false to a string
|
|
167
|
+
// property such as input.value would put the text "false" in the field.
|
|
168
|
+
let old = node[this.attribName];
|
|
169
|
+
node[this.attribName] = typeof old === 'boolean' ? false : '';
|
|
170
|
+
}
|
|
171
|
+
node.removeAttribute(this.attribName);
|
|
153
172
|
}
|
|
154
173
|
else if (expr === true) {
|
|
155
174
|
if (isProp)
|
|
156
|
-
node[this.
|
|
157
|
-
node.setAttribute(this.
|
|
175
|
+
node[this.attribName] = true;
|
|
176
|
+
node.setAttribute(this.attribName, '');
|
|
158
177
|
}
|
|
159
178
|
|
|
160
179
|
// A non-toggled attribute
|
|
161
180
|
else {
|
|
162
181
|
// Only update attributes if the value has changed.
|
|
163
182
|
// This is needed for setting input.value, .checked, option.selected, etc.
|
|
164
|
-
//
|
|
183
|
+
// Non-property attributes never reach here with '', since that removes above.
|
|
165
184
|
let oldVal = isProp
|
|
166
|
-
? node[this.
|
|
167
|
-
: node.getAttribute(this.
|
|
185
|
+
? node[this.attribName]
|
|
186
|
+
: node.getAttribute(this.attribName) ?? '';
|
|
168
187
|
if (oldVal !== expr) {
|
|
169
188
|
|
|
170
189
|
// <textarea value=${expr}></textarea>
|
|
171
190
|
// Without this branch we have no way to set the value of a textarea,
|
|
172
191
|
// since we also prohibit expressions that are a child of textarea.
|
|
173
192
|
if (isProp)
|
|
174
|
-
node[this.
|
|
193
|
+
node[this.attribName] = expr;
|
|
175
194
|
|
|
176
195
|
// Allow one-way binding to contenteditable value attribute.
|
|
177
196
|
// Contenteditables normally don't have a value attribute and have their content set via innerHTML.
|
|
178
197
|
// Solarite doesn't allow contenteditables to have expressions as their children.
|
|
179
|
-
else if (this.
|
|
198
|
+
else if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
|
|
180
199
|
node.innerHTML = expr;
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
// TODO: Putting an 'else' here would be more performant
|
|
184
|
-
node.setAttribute(this.
|
|
203
|
+
node.setAttribute(this.attribName, expr);
|
|
185
204
|
}
|
|
186
205
|
}
|
|
187
206
|
}
|
|
@@ -195,14 +214,14 @@ export default class PathToAttribValue extends Path {
|
|
|
195
214
|
* @return {string} The joined values of the expressions, or the first expression if there are no strings. */
|
|
196
215
|
getValue(exprs) {
|
|
197
216
|
|
|
198
|
-
//#
|
|
217
|
+
//#IFDEBUG
|
|
199
218
|
assert(Array.isArray(exprs));
|
|
200
219
|
//#ENDIF
|
|
201
220
|
//if (!Array.isArray(exprs))
|
|
202
221
|
// return exprs;
|
|
203
222
|
|
|
204
223
|
if (!this.attrValue) {// If it's not multiple paths inside a single attribute, return first (and only) expression.
|
|
205
|
-
//#
|
|
224
|
+
//#IFDEBUG
|
|
206
225
|
assert(exprs.length === 1);
|
|
207
226
|
//#ENDIF
|
|
208
227
|
return exprs[0];
|
|
@@ -213,6 +232,18 @@ export default class PathToAttribValue extends Path {
|
|
|
213
232
|
for (let i = 0; i < values.length; i++) {
|
|
214
233
|
result.push(values[i]);
|
|
215
234
|
if (i < values.length - 1) {
|
|
235
|
+
// A selection binding has to own the whole attribute, because its whole point is
|
|
236
|
+
// writing that attribute without re-rendering, which it can't do if the rest of
|
|
237
|
+
// the value comes from expressions it doesn't know about. Whether a selector sits
|
|
238
|
+
// inside a multi-part attribute is fixed by the shape of the template and never by
|
|
239
|
+
// the data, so this can only be an authoring mistake, and it always surfaces on the
|
|
240
|
+
// template's very first render -- exactly like the placement check in
|
|
241
|
+
// SelectorRef.bind(). That makes it safe to strip from the built file, where the
|
|
242
|
+
// throw is the only thing lost: makePrimitive() then turns the ref into '' and the
|
|
243
|
+
// attribute is written from its constant parts alone. Stripping it also keeps a
|
|
244
|
+
// per-expression instanceof out of the multi-part attribute loop.
|
|
245
|
+
if (typeof exprs[i] === 'object' && exprs[i] instanceof SelectorRef)
|
|
246
|
+
throw new Error(`Solarite: a selector must own the whole ${this.attribName} attribute.`);
|
|
216
247
|
let val = Util.makePrimitive(exprs[i]);
|
|
217
248
|
if (!Util.isFalsy(val))
|
|
218
249
|
result.push(val);
|
|
@@ -233,17 +264,41 @@ export default class PathToAttribValue extends Path {
|
|
|
233
264
|
/**
|
|
234
265
|
* @param funcAndArgs {?Array} The [func, ...args] array from the template, or null if func stands alone. */
|
|
235
266
|
bindEvent(node, root, key, eventName, func, funcAndArgs, capture=false) {
|
|
267
|
+
//#IFDEBUG
|
|
268
|
+
// Both callers already guarantee a function, so this only catches a future third caller.
|
|
269
|
+
// PathToEvent.applySingle() rejects every shape a template can produce and names the
|
|
270
|
+
// offending value, and the two-way binding path above passes a closure it just made
|
|
271
|
+
// here, so nothing a page author writes can reach this line. That makes it dev-only:
|
|
272
|
+
// stripping it from the built file costs no diagnostic that the surviving throw in
|
|
273
|
+
// PathToEvent doesn't already give, with a better message.
|
|
236
274
|
if (typeof func !== 'function')
|
|
237
|
-
throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.
|
|
275
|
+
throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attribName}=\${${func}}> because it's not a function.`);
|
|
276
|
+
//#ENDIF
|
|
238
277
|
|
|
239
|
-
//
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
|
|
278
|
+
// Delegated path: a bubbling event (when the root's options allow it, the default)
|
|
279
|
+
// stores its handler directly on the node as a per-event-type Symbol expando, with no
|
|
280
|
+
// EventBinding object and no addEventListener call. The root-level dispatcher reads
|
|
281
|
+
// these expandos while walking up from the event target. Re-renders just overwrite
|
|
282
|
+
// the property. this.delegatedKey is set by the PathToEvent constructor only for
|
|
283
|
+
// delegatable event names, so this test also excludes non-bubbling events.
|
|
284
|
+
if (capture === false && this.delegatedKey !== undefined) {
|
|
285
|
+
let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
|
|
286
|
+
let toDocument = opt === 'document';
|
|
287
|
+
if (opt !== false && (opt === true || toDocument || opt.includes(eventName))) {
|
|
288
|
+
let dk = this.delegatedKey;
|
|
289
|
+
if (node[dk] === undefined) // First binding of this type on this node.
|
|
290
|
+
ensureDelegatedDispatcher(root, eventName, toDocument);
|
|
291
|
+
// Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
|
|
292
|
+
// template's own [func, ...args] array; a plain function is stored bare.
|
|
293
|
+
// Either way, nothing is allocated.
|
|
294
|
+
node[dk] = funcAndArgs || func;
|
|
295
|
+
node[delegatedRootKey] = root;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
243
299
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// a plain function allocates a one-element array, which is rare (buttons, two-way).
|
|
300
|
+
// Direct path: capture bindings, non-bubbling events, and eventDelegation:false.
|
|
301
|
+
// Store the callable as a single [func, ...args] array.
|
|
247
302
|
let args = funcAndArgs || [func];
|
|
248
303
|
|
|
249
304
|
// One stable EventBinding object per node+key is registered with addEventListener
|
|
@@ -253,7 +308,7 @@ export default class PathToAttribValue extends Path {
|
|
|
253
308
|
let nodeEvents = node[eventBindingsKey];
|
|
254
309
|
if (nodeEvents === undefined) {
|
|
255
310
|
let b = node[eventBindingsKey] = new EventBinding(root, node, key, args);
|
|
256
|
-
|
|
311
|
+
node.addEventListener(eventName, b, capture);
|
|
257
312
|
return;
|
|
258
313
|
}
|
|
259
314
|
|
|
@@ -271,7 +326,7 @@ export default class PathToAttribValue extends Path {
|
|
|
271
326
|
let map = node[eventBindingsKey] = {};
|
|
272
327
|
map[nodeEvents.key] = nodeEvents;
|
|
273
328
|
binding = map[key] = new EventBinding(root, node, key, args);
|
|
274
|
-
|
|
329
|
+
node.addEventListener(eventName, binding, capture);
|
|
275
330
|
return;
|
|
276
331
|
}
|
|
277
332
|
}
|
|
@@ -279,11 +334,11 @@ export default class PathToAttribValue extends Path {
|
|
|
279
334
|
binding = nodeEvents[key];
|
|
280
335
|
if (!binding) {
|
|
281
336
|
binding = nodeEvents[key] = new EventBinding(root, node, key, args);
|
|
282
|
-
|
|
337
|
+
node.addEventListener(eventName, binding, capture);
|
|
283
338
|
return;
|
|
284
339
|
}
|
|
285
340
|
}
|
|
286
|
-
binding.
|
|
341
|
+
binding.rootEl = root;
|
|
287
342
|
binding.args = args;
|
|
288
343
|
}
|
|
289
344
|
}
|
|
@@ -304,73 +359,99 @@ export function getEventBinding(node, key) {
|
|
|
304
359
|
return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
|
|
305
360
|
}
|
|
306
361
|
|
|
307
|
-
/**
|
|
308
|
-
* Attach a new EventBinding either directly or through the root component's delegated
|
|
309
|
-
* dispatcher. The dispatcher lives on the root element (not the document) so a component
|
|
310
|
-
* still receives delegated events while detached from the document, and events stay scoped
|
|
311
|
-
* to the component that rendered them. */
|
|
312
|
-
function registerBinding(binding, node, eventName, capture, options, root) {
|
|
313
|
-
// Bubbling events are delegated by default: they skip addEventListener entirely, and one
|
|
314
|
-
// root-level dispatcher per event type finds bindings by walking up from the event target.
|
|
315
|
-
// eventDelegation:false opts out; an array delegates only the named events. Capture
|
|
316
|
-
// bindings and non-bubbling events always stay direct.
|
|
317
|
-
let delegate = false;
|
|
318
|
-
if (capture === false) {
|
|
319
|
-
let opt = options?.eventDelegation ?? true;
|
|
320
|
-
if (opt !== false && delegatableEvents.has(eventName))
|
|
321
|
-
delegate = opt === true || opt.includes(eventName);
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
if (delegate) {
|
|
325
|
-
binding.delegated = true;
|
|
326
|
-
let types = root[delegatedTypesKey];
|
|
327
|
-
if (types === undefined)
|
|
328
|
-
types = root[delegatedTypesKey] = new Set();
|
|
329
|
-
if (!types.has(eventName)) {
|
|
330
|
-
types.add(eventName);
|
|
331
|
-
root.addEventListener(eventName, delegatedDispatcher);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
else
|
|
335
|
-
node.addEventListener(eventName, binding, capture);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
362
|
// Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
|
|
339
363
|
const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
|
|
340
364
|
'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
|
|
341
365
|
'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
|
|
342
366
|
|
|
367
|
+
// One Symbol per delegated event type; nodes store their delegated handler under it.
|
|
368
|
+
// Symbols (vs string expandos like Solid's $$click) can't collide with user properties.
|
|
369
|
+
const delegatedKeys = {};
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Get the per-event-type Symbol key, or undefined for non-delegatable events.
|
|
373
|
+
* Called once per PathToEvent construction, never per bind.
|
|
374
|
+
* @param eventName {string}
|
|
375
|
+
* @return {symbol|undefined} */
|
|
376
|
+
export function delegatedKeyFor(eventName) {
|
|
377
|
+
if (!delegatableEvents.has(eventName))
|
|
378
|
+
return undefined;
|
|
379
|
+
return delegatedKeys[eventName] ??= Symbol('sol$' + eventName);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// The component root a node's delegated handlers run with as `this`.
|
|
383
|
+
// Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
|
|
384
|
+
export const delegatedRootKey = Symbol('solariteDelegatedRoot');
|
|
385
|
+
|
|
343
386
|
// Per-root-element Set of event types that already have a delegated dispatcher registered.
|
|
344
387
|
const delegatedTypesKey = Symbol('solariteDelegatedTypes');
|
|
345
388
|
|
|
389
|
+
/**
|
|
390
|
+
* Register the delegated dispatcher for eventName on root if it isn't already.
|
|
391
|
+
* Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
|
|
392
|
+
*
|
|
393
|
+
* With andDocument (the eventDelegation:'document' render option), the dispatcher is also
|
|
394
|
+
* registered on the document, once per event type: a bound node that gets re-parented
|
|
395
|
+
* OUTSIDE its root (e.g. a toolbar a dock parks in its own chrome) bubbles past the root's
|
|
396
|
+
* listener, and only a document-level listener can still reach its handler. The
|
|
397
|
+
* delegatedDoneKey marker keeps the two dispatchers from double-running the same event.
|
|
398
|
+
* @param root {HTMLElement}
|
|
399
|
+
* @param eventName {string}
|
|
400
|
+
* @param andDocument {boolean} */
|
|
401
|
+
export function ensureDelegatedDispatcher(root, eventName, andDocument=false) {
|
|
402
|
+
let types = root[delegatedTypesKey];
|
|
403
|
+
if (types === undefined)
|
|
404
|
+
types = root[delegatedTypesKey] = new Set();
|
|
405
|
+
if (!types.has(eventName)) {
|
|
406
|
+
types.add(eventName);
|
|
407
|
+
root.addEventListener(eventName, delegatedDispatcher);
|
|
408
|
+
}
|
|
409
|
+
if (andDocument) {
|
|
410
|
+
let doc = root.ownerDocument ?? document;
|
|
411
|
+
let docTypes = doc[delegatedTypesKey];
|
|
412
|
+
if (docTypes === undefined)
|
|
413
|
+
docTypes = doc[delegatedTypesKey] = new Set();
|
|
414
|
+
if (!docTypes.has(eventName)) {
|
|
415
|
+
docTypes.add(eventName);
|
|
416
|
+
doc.addEventListener(eventName, delegatedDispatcher);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
346
421
|
// Marks an event the innermost root dispatcher has already walked, so an outer root's
|
|
347
422
|
// listener (when components are nested) skips it instead of dispatching the bindings again.
|
|
348
423
|
const delegatedDoneKey = Symbol('solariteDelegated');
|
|
349
424
|
|
|
350
425
|
/**
|
|
351
426
|
* The per-root listener for each delegated event type. The first (innermost) root the
|
|
352
|
-
* bubbling event reaches walks from the event target upward, invoking delegated
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* bubbling. */
|
|
427
|
+
* bubbling event reaches walks from the event target upward, invoking delegated handlers
|
|
428
|
+
* stored on the nodes along the way; outer roots then see the done-marker and skip.
|
|
429
|
+
* Each node carries the root its handlers run with as `this` (see delegatedRootKey), so
|
|
430
|
+
* handlers in an outer component still run with the correct component. event.currentTarget
|
|
431
|
+
* is patched to the node whose handler is running, and restored after. stopPropagation()
|
|
432
|
+
* inside a handler ends the walk, mirroring native bubbling. */
|
|
358
433
|
function delegatedDispatcher(ev) {
|
|
359
434
|
if (ev[delegatedDoneKey])
|
|
360
435
|
return;
|
|
361
436
|
ev[delegatedDoneKey] = true;
|
|
362
|
-
let
|
|
437
|
+
let dk = delegatedKeys[ev.type];
|
|
363
438
|
let current = ev.target;
|
|
364
439
|
Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
|
|
365
440
|
while (current) {
|
|
366
|
-
let
|
|
367
|
-
if (
|
|
368
|
-
let
|
|
369
|
-
if (
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
441
|
+
let a = current[dk];
|
|
442
|
+
if (a !== undefined) {
|
|
443
|
+
let root = current[delegatedRootKey];
|
|
444
|
+
if (typeof a === 'function')
|
|
445
|
+
a.call(root, ev, current);
|
|
446
|
+
else
|
|
447
|
+
switch (a.length) {
|
|
448
|
+
case 1: a[0].call(root, ev, current); break;
|
|
449
|
+
case 2: a[0].call(root, a[1], ev, current); break;
|
|
450
|
+
case 3: a[0].call(root, a[1], a[2], ev, current); break;
|
|
451
|
+
default: a[0].call(root, ...a.slice(1), ev, current);
|
|
452
|
+
}
|
|
453
|
+
if (ev.cancelBubble)
|
|
454
|
+
break;
|
|
374
455
|
}
|
|
375
456
|
current = current.parentNode;
|
|
376
457
|
}
|
|
@@ -379,7 +460,7 @@ function delegatedDispatcher(ev) {
|
|
|
379
460
|
|
|
380
461
|
class EventBinding {
|
|
381
462
|
constructor(root, node, key, args) {
|
|
382
|
-
this.
|
|
463
|
+
this.rootEl = root;
|
|
383
464
|
this.node = node;
|
|
384
465
|
this.key = key;
|
|
385
466
|
|
|
@@ -393,10 +474,10 @@ class EventBinding {
|
|
|
393
474
|
'handleEvent'(event) {
|
|
394
475
|
let a = this.args;
|
|
395
476
|
switch (a.length) {
|
|
396
|
-
case 1: return a[0].call(this.
|
|
397
|
-
case 2: return a[0].call(this.
|
|
398
|
-
case 3: return a[0].call(this.
|
|
477
|
+
case 1: return a[0].call(this.rootEl, event, this.node);
|
|
478
|
+
case 2: return a[0].call(this.rootEl, a[1], event, this.node);
|
|
479
|
+
case 3: return a[0].call(this.rootEl, a[1], a[2], event, this.node);
|
|
399
480
|
}
|
|
400
|
-
return a[0].call(this.
|
|
481
|
+
return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
|
|
401
482
|
}
|
|
402
483
|
}
|