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/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
- /*#IFDEV*/this.verify();/*#ENDIF*/
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
- apply(exprs) {}
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
- * Resolve nodeMarkerPath to new root.
90
- * TODO: Make clone() use this.*/
91
- getNewNodeMarker(newRoot, pathOffset) {
92
- let root = newRoot;
93
- let path = this.nodeMarkerPath;
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.attrName, this.attrValue);
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
- /*#IFDEV*/this.verify();/*#ENDIF*/
129
-
130
- // Resolve node paths.
131
- let nodeMarker, nodeBefore;
132
- let root = newRoot;
133
- let path = this.nodeMarkerPath;
134
- let pathLength = path.length - pathOffset;
135
- for (let i=pathLength-1; i>0; i--) { // Resolve the path.
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
- //#IFDEV
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 = new this.constructor(nodeBefore, nodeMarker, this.attrName, this.attrValue);
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
- //#IFDEV
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
- //#IFDEV
192
+ //#IFDEBUG
194
193
 
195
194
  /** @return {HTMLElement|ParentNode} */
196
195
  getParentNode() {
@@ -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
- attrName;
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
- /** @type {boolean} Provides value for attribute on a component. */
16
- isComponent;
16
+ // isComponentAttrib and isHtmlProperty are declared on the Path base class.
17
17
 
18
- isHtmlProperty;
19
-
20
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
18
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
21
19
  super(null, nodeMarker);
22
- this.attrName = attrName;
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
- apply(exprs) {
30
- //#IFDEV
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.attrName]
44
- : node.getAttribute(this.attrName);
41
+ ? node[this.attribName]
42
+ : node.getAttribute(this.attribName);
45
43
  if (oldVal !== joinedValue) {
46
44
  if (isProp)
47
- node[this.attrName] = joinedValue;
48
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable'))
45
+ node[this.attribName] = joinedValue;
46
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
49
47
  node.innerHTML = joinedValue;
50
- node.setAttribute(this.attrName, joinedValue);
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.apply([expr]);
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 to <${node.tagName.toLowerCase()} ${this.attrName}=\${[${expr.map(item => item ? `'${item}'` : item+'').join(', ')}]}>.`);
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.attrName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
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.attrName === 'value' && node.hasAttribute('contenteditable')) {
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.attrName])
115
- node[this.attrName] = strValue;
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.attrName === 'value' || node.type === 'radio')
120
+ let value = (this.attribName === 'value' || node.type === 'radio')
123
121
  ? Util.getInputValue(node)
124
- : node[this.attrName];
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.getRootNode(), this.attrName, 'input', func, null, true);
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 to toggle an attribute
149
- if (expr === undefined || expr === false || expr === null) { // Util.isFalsy() inlined.
150
- if (isProp)
151
- node[this.attrName] = false;
152
- node.removeAttribute(this.attrName);
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.attrName] = true;
157
- node.setAttribute(this.attrName, '');
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
- // A missing attribute counts as '', so empty values don't write empty attributes.
183
+ // Non-property attributes never reach here with '', since that removes above.
165
184
  let oldVal = isProp
166
- ? node[this.attrName]
167
- : node.getAttribute(this.attrName) ?? '';
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.attrName] = expr;
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.attrName === 'value' && node.hasAttribute('contenteditable')) {
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.attrName, expr);
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
- //#IFDEV
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
- //#IFDEV
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.attrName}=\${${func}}> because it's not a function.`);
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
- // Whether to delegate is decided in registerBinding(), which only runs for a NEW binding.
240
- // Re-renders rebind existing rows (just updating binding.args below), so they skip the
241
- // options lookup + delegatableEvents check entirely.
242
- let options = this.parentNg.rootNg.options;
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
- // Store the callable as a single [func, ...args] array. Array-form bindings
245
- // (onclick=${[fn, arg]}, the hot per-row case) pass it through with no allocation;
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
- registerBinding(b, node, eventName, capture, options, root);
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
- registerBinding(binding, node, eventName, capture, options, root);
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
- registerBinding(binding, node, eventName, capture, options, root);
337
+ node.addEventListener(eventName, binding, capture);
283
338
  return;
284
339
  }
285
340
  }
286
- binding.root = root;
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
- * EventBindings stored on the nodes along the way; outer roots then see the done-marker and
354
- * skip. Each binding carries its own root, so handlers in an outer component still run with
355
- * the correct `this`. event.currentTarget is patched to the node whose binding is running,
356
- * and restored after. stopPropagation() inside a handler ends the walk, mirroring native
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 type = ev.type;
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 b = current[eventBindingsKey];
367
- if (b !== undefined) {
368
- let binding = b instanceof EventBinding ? b : b[type];
369
- if (binding !== undefined && binding.delegated === true && binding.key === type) {
370
- binding.handleEvent(ev);
371
- if (ev.cancelBubble)
372
- break;
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.root = root;
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.root, event, this.node);
397
- case 2: return a[0].call(this.root, a[1], event, this.node);
398
- case 3: return a[0].call(this.root, a[1], a[2], event, this.node);
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.root, ...a.slice(1), event, this.node);
481
+ return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
401
482
  }
402
483
  }