@shortfuse/materialdesignweb 0.7.6 → 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.
Files changed (114) hide show
  1. package/README.md +57 -68
  2. package/components/Badge.js +2 -2
  3. package/components/BottomAppBar.js +3 -5
  4. package/components/Box.js +33 -3
  5. package/components/Button.js +48 -21
  6. package/components/Button.md +9 -9
  7. package/components/Card.js +9 -16
  8. package/components/Checkbox.js +45 -36
  9. package/components/CheckboxIcon.js +2 -2
  10. package/components/Chip.js +1 -1
  11. package/components/Dialog.js +228 -359
  12. package/components/DialogActions.js +2 -2
  13. package/components/Divider.js +3 -3
  14. package/components/ExtendedFab.js +4 -8
  15. package/components/Fab.js +1 -2
  16. package/components/FilterChip.js +4 -4
  17. package/components/Headline.js +1 -1
  18. package/components/Icon.js +8 -8
  19. package/components/IconButton.js +9 -14
  20. package/components/Input.js +273 -1
  21. package/components/Layout.js +485 -16
  22. package/components/List.js +6 -4
  23. package/components/ListItem.js +12 -12
  24. package/components/ListOption.js +21 -5
  25. package/components/Listbox.js +239 -0
  26. package/components/Menu.js +77 -526
  27. package/components/MenuItem.js +12 -14
  28. package/components/Nav.js +0 -2
  29. package/components/NavBar.js +8 -79
  30. package/components/NavDrawer.js +12 -11
  31. package/components/NavDrawerItem.js +2 -1
  32. package/components/NavItem.js +18 -8
  33. package/components/NavRail.js +15 -7
  34. package/components/NavRailItem.js +3 -1
  35. package/components/Popup.js +20 -0
  36. package/components/Progress.js +24 -23
  37. package/components/Radio.js +42 -35
  38. package/components/RadioIcon.js +3 -3
  39. package/components/Ripple.js +2 -3
  40. package/components/Search.js +85 -0
  41. package/components/SegmentedButton.js +1 -10
  42. package/components/SegmentedButtonGroup.js +16 -10
  43. package/components/Select.js +4 -4
  44. package/components/Shape.js +1 -1
  45. package/components/Slider.js +43 -50
  46. package/components/Snackbar.js +4 -5
  47. package/components/Surface.js +3 -3
  48. package/components/Switch.js +55 -21
  49. package/components/SwitchIcon.js +10 -8
  50. package/components/Tab.js +11 -9
  51. package/components/TabContent.js +4 -3
  52. package/components/TabList.js +2 -2
  53. package/components/TabPanel.js +11 -8
  54. package/components/TextArea.js +38 -35
  55. package/components/Tooltip.js +2 -2
  56. package/components/TopAppBar.js +65 -147
  57. package/core/Composition.js +985 -628
  58. package/core/CompositionAdapter.js +315 -0
  59. package/core/CustomElement.js +153 -90
  60. package/core/DomAdapter.js +586 -0
  61. package/core/ICustomElement.d.ts +2 -2
  62. package/core/css.js +8 -7
  63. package/core/customTypes.js +53 -31
  64. package/{utils → core}/jsonMergePatch.js +36 -14
  65. package/core/observe.js +111 -57
  66. package/core/optimizations.js +23 -0
  67. package/core/template.js +17 -11
  68. package/core/test.js +126 -0
  69. package/core/typings.d.ts +11 -5
  70. package/core/uid.js +13 -0
  71. package/dist/index.min.js +83 -152
  72. package/dist/index.min.js.map +4 -4
  73. package/dist/meta.json +1 -1
  74. package/mixins/AriaReflectorMixin.js +1 -2
  75. package/mixins/AriaToolbarMixin.js +2 -3
  76. package/mixins/ControlMixin.js +25 -17
  77. package/mixins/DensityMixin.js +0 -1
  78. package/mixins/FlexableMixin.js +1 -2
  79. package/mixins/FormAssociatedMixin.js +13 -10
  80. package/mixins/InputMixin.js +2 -9
  81. package/mixins/KeyboardNavMixin.js +14 -1
  82. package/mixins/PopupMixin.js +757 -0
  83. package/mixins/RTLObserverMixin.js +0 -1
  84. package/mixins/ResizeObserverMixin.js +0 -1
  85. package/mixins/RippleMixin.js +3 -4
  86. package/mixins/ScrollListenerMixin.js +41 -32
  87. package/mixins/SemiStickyMixin.js +151 -0
  88. package/mixins/ShapeMixin.js +29 -24
  89. package/mixins/StateMixin.js +11 -6
  90. package/mixins/SurfaceMixin.js +3 -57
  91. package/mixins/TextFieldMixin.js +57 -65
  92. package/mixins/ThemableMixin.js +78 -156
  93. package/mixins/TooltipTriggerMixin.js +7 -13
  94. package/mixins/TouchTargetMixin.js +4 -3
  95. package/package.json +9 -5
  96. package/theming/index.js +1 -1
  97. package/theming/themableMixinLoader.js +12 -0
  98. package/utils/{hct → material-color}/blend.js +8 -10
  99. package/utils/{hct → material-color/hct}/Cam16.js +196 -69
  100. package/utils/{hct → material-color/hct}/Hct.js +61 -19
  101. package/utils/{hct → material-color/hct}/ViewingConditions.js +3 -3
  102. package/utils/{hct → material-color/hct}/hctSolver.js +9 -16
  103. package/utils/{hct → material-color}/helper.js +11 -18
  104. package/utils/{hct → material-color/palettes}/CorePalette.js +79 -19
  105. package/utils/{hct → material-color/palettes}/TonalPalette.js +12 -4
  106. package/utils/material-color/scheme/Scheme.js +376 -0
  107. package/utils/{hct/colorUtils.js → material-color/utils/color.js} +61 -1
  108. package/utils/popup.js +46 -25
  109. package/components/ListSelect.js +0 -220
  110. package/components/Option.js +0 -91
  111. package/components/Pane.js +0 -281
  112. package/core/identify.js +0 -40
  113. package/utils/hct/Scheme.js +0 -587
  114. /package/utils/{hct/mathUtils.js → material-color/utils/math.js} +0 -0
@@ -1,12 +1,15 @@
1
1
  /* eslint-disable sort-class-members/sort-class-members */
2
+
3
+ import CompositionAdapter from './CompositionAdapter.js';
2
4
  import { generateCSSStyleSheets, generateHTMLStyleElements } from './css.js';
3
- import { identifierFromElement } from './identify.js';
4
5
  import { observeFunction } from './observe.js';
6
+ import { createEmptyComment, createEmptyTextNode } from './optimizations.js';
5
7
  import { generateFragment, inlineFunctions } from './template.js';
8
+ import { generateUID } from './uid.js';
6
9
 
7
10
  /**
8
11
  * @template T
9
- * @typedef {Composition<?>|HTMLStyleElement|CSSStyleSheet|DocumentFragment|((this:T, changes:T) => any)|string} CompositionPart
12
+ * @typedef {Composition<?>|HTMLStyleElement|CSSStyleSheet|DocumentFragment|string} CompositionPart
10
13
  */
11
14
 
12
15
  /**
@@ -18,30 +21,228 @@ import { generateFragment, inlineFunctions } from './template.js';
18
21
 
19
22
  /**
20
23
  * @template T
21
- * @typedef {Object} WatcherBindEntry
22
- * @prop {Function} fn
23
- * @prop {Set<keyof T & string>} props
24
+ * @typedef {Object} RenderOptions
25
+ * @prop {T} [store] what
26
+ * @prop {DocumentFragment|ShadowRoot|HTMLElement|Element} [target] where
27
+ * @prop {any} [context] `this` on callbacks/events
28
+ * @prop {any} [injections]
24
29
  */
25
30
 
26
31
  /**
27
32
  * @template {any} T
28
33
  * @typedef {Object} NodeBindEntry
29
- * @prop {string} id
30
- * @prop {number} nodeType
31
- * @prop {string} node
34
+ * @prop {string} [key]
35
+ * @prop {number} [index]
36
+ * @prop {string} tag
37
+ * @prop {string|number} subnode Index of childNode or attrName
38
+ * @prop {string[]} props
39
+ * @prop {string[][]} deepProps
32
40
  * @prop {boolean} [negate]
33
41
  * @prop {boolean} [doubleNegate]
34
- * @prop {Function} [fn]
35
- * @prop {Set<keyof T & string>} props
42
+ * @prop {Function} [expression]
43
+ * @prop {(options: RenderOptions<?>, element: Element, changes:any, data:any) => any} [render] custom render function
44
+ * @prop {import('./typings.js').CompositionEventListener<T>[]} [listeners]
45
+ * @prop {Composition<any>} [composition] // Sub composition templating (eg: array)
36
46
  * @prop {T} defaultValue
37
47
  */
38
48
 
49
+ /** @typedef {any[]} RenderState */
50
+
51
+ /**
52
+ * @typedef RenderGraphSearch
53
+ * @prop {(state:InitializationState, changes:any, data:any) => any} invocation
54
+ * @prop {number} cacheIndex
55
+ * @prop {number} ranFlagIndex
56
+ * @prop {number} dirtyIndex
57
+ * @prop {string | Function | string[]} query
58
+ * @prop {Function} [expression]
59
+ * @prop {string} prop
60
+ * @prop {string[]} deepProp
61
+ * @prop {string[]} propsUsed
62
+ * @prop {string[][]} deepPropsUsed
63
+ * @prop {any} defaultValue
64
+ * @prop {RenderGraphSearch} [subSearch]
65
+ */
66
+
67
+ /**
68
+ * @typedef RenderGraphAction
69
+ * @prop {(state:InitializationState, value:any, changes: any, data:any) => any} invocation
70
+ * @prop {number} [commentIndex]
71
+ * @prop {number} [nodeIndex]
72
+ * @prop {number} [cacheIndex]
73
+ * @prop {string} [attrName]
74
+ * @prop {any} [defaultValue]
75
+ * @prop {RenderGraphSearch} search
76
+ */
77
+
78
+ /**
79
+ * @type {RenderGraphAction['invocation']}
80
+ * @this {RenderGraphAction}
81
+ */
82
+ function writeDOMAttribute(state, value) {
83
+ const { nodeIndex, attrName } = this;
84
+ /** @type {Element} */
85
+ const element = state.nodes[nodeIndex];
86
+ switch (value) {
87
+ case undefined:
88
+ case null:
89
+ case false:
90
+ element.removeAttribute(attrName);
91
+ return false;
92
+ case true:
93
+ element.setAttribute(attrName, '');
94
+ return '';
95
+ default:
96
+ element.setAttribute(attrName, value);
97
+ return value;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * @type {RenderGraphAction['invocation']}
103
+ * @this {RenderGraphAction}
104
+ */
105
+ function writeDOMText(state, value) {
106
+ // @ts-ignore Skip cast
107
+ state.nodes[this.nodeIndex].data = value;
108
+ }
109
+
110
+ /**
111
+ * @type {RenderGraphAction['invocation']}
112
+ * @this {RenderGraphAction}
113
+ */
114
+ function writeDOMElementAttachedState(state, value) {
115
+ const { commentIndex, nodeIndex } = this;
116
+ let comment = state.comments[commentIndex];
117
+ if (!comment) {
118
+ comment = createEmptyComment();
119
+ state.comments[commentIndex] = comment;
120
+ }
121
+ const element = state.nodes[nodeIndex];
122
+ const show = value != null && value !== false;
123
+ if (show) {
124
+ comment.replaceWith(element);
125
+ } else {
126
+ element.replaceWith(comment);
127
+ }
128
+ return show;
129
+ }
130
+
131
+ /**
132
+ * @type {RenderGraphAction['invocation']}
133
+ * @this {RenderGraphAction}
134
+ */
135
+ function writeDOMHideElementOnInit(state) {
136
+ const { commentIndex, nodeIndex } = this;
137
+
138
+ const comment = createEmptyComment();
139
+ state.comments[commentIndex] = comment;
140
+
141
+ state.nodes[nodeIndex].replaceWith(comment);
142
+ }
143
+
144
+ /**
145
+ * @param {RenderGraphSearch} search
146
+ * @param {Parameters<RenderGraphSearch['invocation']>} args
147
+ */
148
+ function executeSearch(search, ...args) {
149
+ const [state] = args;
150
+ const cachedValue = state.caches[search.cacheIndex];
151
+ if (state.ranFlags[search.ranFlagIndex]) {
152
+ // Return last result
153
+ return {
154
+ value: cachedValue,
155
+ dirty: state.dirtyFlags[search.dirtyIndex],
156
+ };
157
+ }
158
+ state.ranFlags[search.ranFlagIndex] = true;
159
+ let result;
160
+ if (search.subSearch) {
161
+ const subResult = executeSearch(search.subSearch, ...args);
162
+ // Use last cached value (if any)
163
+ if (!subResult.dirty && cachedValue !== undefined) {
164
+ state.dirtyFlags[search.dirtyIndex] = false;
165
+ return { value: cachedValue, dirty: false };
166
+ }
167
+ // Pass from subquery
168
+ result = search.invocation(subResult.value);
169
+ } else {
170
+ result = search.invocation(...args);
171
+ }
172
+ if ((result === undefined) || (cachedValue === result)) {
173
+ // Returnf rom cache
174
+ return { value: result, dirty: false };
175
+ }
176
+
177
+ // Overwrite cache and flag as dirty
178
+ state.caches[search.cacheIndex] = result;
179
+ state.dirtyFlags[search.dirtyIndex] = true;
180
+ return { value: result, dirty: true };
181
+ }
182
+
183
+ /**
184
+ * @type {RenderGraphSearch['invocation']}
185
+ * @this {RenderGraphSearch}
186
+ */
187
+ function searchWithExpression(state, changes, data) {
188
+ return this.expression.call(
189
+ state.options.context,
190
+ state.options.store ?? data,
191
+ state.options.injections,
192
+ );
193
+ }
194
+
195
+ /**
196
+ * @type {RenderGraphSearch['invocation']}
197
+ * @this {RenderGraphSearch}
198
+ */
199
+ function searchWithProp(state, changes, data) {
200
+ return changes[this.prop];
201
+ }
202
+
203
+ /**
204
+ * @type {RenderGraphSearch['invocation']}
205
+ * @this {RenderGraphSearch}
206
+ */
207
+ function searchWithDeepProp(state, changes, data) {
208
+ let scope = changes;
209
+ for (const prop of this.deepProp) {
210
+ if (scope === null) return null;
211
+ if (prop in scope === false) return undefined;
212
+ scope = scope[prop];
213
+ }
214
+ return scope;
215
+ }
216
+
217
+ /**
218
+ * @typedef InterpolateOptions
219
+ * @prop {Object} [defaults] Default values to use for interpolation
220
+ * @prop {{iterable:string} & Record<string,any>} [injections] Context-specific injected properties. (Experimental)
221
+ */
222
+
223
+ /**
224
+ * @typedef InitializationState
225
+ * @prop {Element} lastElement
226
+ * @prop {boolean} isShadowRoot
227
+ * @prop {ChildNode} lastChildNode
228
+ * @prop {(Element|Text)[]} nodes
229
+ * @prop {any[]} caches
230
+ * @prop {Comment[]} comments
231
+ * @prop {boolean[]} ranFlags
232
+ * @prop {boolean[]} dirtyFlags
233
+ * @prop {Element[]} refs
234
+ * @prop {number} lastChildNodeIndex
235
+ * @prop {DocumentFragment} instanceFragment
236
+ * @prop {RenderOptions<?>} options
237
+ */
238
+
39
239
  /** Splits: `{template}text{template}` as `['', 'template', 'text', 'template', '']` */
40
240
  const STRING_INTERPOLATION_REGEX = /{([^}]*)}/g;
41
241
 
42
242
  /**
43
243
  * Returns event listener bound to shadow root host.
44
244
  * Use this function to avoid generating extra closures
245
+ *
45
246
  * @this {HTMLElement}
46
247
  * @param {Function} fn
47
248
  */
@@ -54,33 +255,23 @@ function buildShadowRootChildListener(fn) {
54
255
  }
55
256
 
56
257
  /**
57
- *
58
- * @param {Object} object
59
- * @param {'dot'|'bracket'} [syntax]
60
- * @param {Object} [target]
61
- * @param {string} [scope]
62
- * @return {Object}
258
+ * @example
259
+ * propFromObject('foo', {foo:'bar'}) == ['foo', 'bar'];
260
+ * @param {string} prop
261
+ * @param {any} source
262
+ * @return {any}
63
263
  */
64
- function flattenObject(object, syntax = 'dot', target = {}, scope = '') {
65
- for (const [key, value] of Object.entries(object)) {
66
- if (!key) continue; // Blank keys are not supported;
67
- const scopedKey = scope ? `${scope}.${key}` : key;
68
- target[scopedKey] = value;
69
- if (value != null && typeof value === 'object') {
70
- flattenObject(value, syntax, target, scopedKey);
71
- }
72
- }
73
- if (Array.isArray(object)) {
74
- const scopedKey = scope ? `${scope}.length` : 'length';
75
- target[scopedKey] = object.length;
264
+ function propFromObject(prop, source) {
265
+ if (source) {
266
+ return source[prop];
76
267
  }
77
- return target;
268
+ return undefined;
78
269
  }
79
270
 
80
271
  /**
81
272
  * @example
82
- * entryFromPropName(
83
- * 'address.home.houseNumber',
273
+ * deepPropFromObject(
274
+ * ['address', 'home, 'houseNumber'],
84
275
  * {
85
276
  * address: {
86
277
  * home: {
@@ -88,22 +279,25 @@ function flattenObject(object, syntax = 'dot', target = {}, scope = '') {
88
279
  * },
89
280
  * }
90
281
  * }
91
- * ) === {value:35}
92
- * @param {string} prop
282
+ * ) == [houseNumber, 35]
283
+ * @param {string[]} nameArray
93
284
  * @param {any} source
94
- * @return {null|[string, any]}
285
+ * @return {any}
95
286
  */
96
- function entryFromPropName(prop, source) {
97
- let value = source;
98
- let child;
99
- for (child of prop.split('.')) {
100
- if (!child) throw new Error(`Invalid property: ${prop}`);
101
- if (child in value === false) return null;
102
- // @ts-ignore Skip cast
103
- value = value[child];
287
+ function deepPropFromObject(nameArray, source) {
288
+ if (!source) return undefined;
289
+ let scope = source;
290
+ let prop;
291
+ for (prop of nameArray) {
292
+ if (typeof scope === 'object') {
293
+ if (scope === null) return null;
294
+ if (!(prop in scope)) return undefined;
295
+ scope = scope[prop];
296
+ } else {
297
+ return scope[prop];
298
+ }
104
299
  }
105
- if (value === source) return null;
106
- return [child, value];
300
+ return scope;
107
301
  }
108
302
 
109
303
  /**
@@ -125,26 +319,84 @@ function valueFromPropName(prop, source) {
125
319
 
126
320
  /** @template T */
127
321
  export default class Composition {
322
+ #interpolationState = {
323
+ nodeIndex: -1,
324
+ ranFlagIndex: 0,
325
+ cacheIndex: 0,
326
+ dirtyIndex: 0,
327
+ commentIndex: 0,
328
+ /** @type {this['nodesToBind'][0]} */
329
+ nodeEntry: null,
330
+ };
331
+
332
+ // eslint-disable-next-line symbol-description
333
+ static shadowRootTag = Symbol();
334
+
335
+ /** @type {{tag:string, textNodes: number[]}[]} */
336
+ nodesToBind = [];
337
+
338
+ /** @type {string[]} */
339
+ props = [];
340
+
341
+ /** @type {RenderGraphSearch[]} */
342
+ searches = [];
343
+
344
+ /** @type {any[]} */
345
+ initCache = [];
346
+
347
+ /**
348
+ * Index of searches by query (dotted notation for deep props)
349
+ *
350
+ * @type {Map<Function|string, RenderGraphSearch>}
351
+ */
352
+ searchByQuery = new Map();
353
+
354
+ /**
355
+ * Index of searches by query (dotted notation for deep props)
356
+ *
357
+ * @type {Map<string, RenderGraphAction[]>}
358
+ */
359
+ actionsByPropsUsed = new Map();
360
+
361
+ /** @type {RenderGraphAction[]} */
362
+ actions = [];
363
+
364
+ /** @type {RenderGraphAction[]} */
365
+ postInitActions = [];
366
+
367
+ /** @type {Set<string>} */
368
+ tagsWithBindings = new Set();
369
+
370
+ /**
371
+ * Array of element tags
372
+ *
373
+ * @type {string[]}
374
+ */
375
+ tags = [];
376
+
128
377
  /**
129
- * Collection of property bindings.
130
- * @type {Map<keyof T & string, Set<NodeBindEntry<?>>>}
378
+ * Array of property bindings sorted by tag/subnode
379
+ *
380
+ * @type {Set<string>}
131
381
  */
132
- bindings = new Map();
382
+ watchedProps = new Set();
133
383
 
134
384
  /**
135
385
  * Data of arrays used in templates
136
- * Usage of a [_for] will create an ArrayLike expectation based on key
386
+ * Usage of a [mdw-for] will create an ArrayLike expectation based on key
137
387
  * Only store metadata, not actual data. Currently only needs length.
138
388
  * TBD if more is needed later
139
389
  * Referenced by property key (string)
140
- * @type {Map<keyof T & string, ArrayMetadata<T>}
390
+ *
391
+ * @type {CompositionAdapter}
141
392
  */
142
- arrayMetadata = new Map();
393
+ adapter;
143
394
 
144
395
  /**
145
396
  * Collection of events to bind.
146
397
  * Indexed by ID
147
- * @type {Map<string, Set<import('./typings.js').CompositionEventListener<any>>>}
398
+ *
399
+ * @type {Map<string|symbol, import('./typings.js').CompositionEventListener<any>[]>}
148
400
  */
149
401
  events = new Map();
150
402
 
@@ -152,18 +404,11 @@ export default class Composition {
152
404
  * Snapshot of composition at initial state.
153
405
  * This fragment can be cloned for first rendering, instead of calling
154
406
  * of using `render()` to construct the initial DOM tree.
407
+ *
155
408
  * @type {DocumentFragment}
156
409
  */
157
410
  cloneable;
158
411
 
159
- /**
160
- * Result of interpolation of the composition template.
161
- * Includes all DOM elements, which is used to reference for adding and
162
- * removing DOM elements during render.
163
- * @type {DocumentFragment}
164
- */
165
- interpolation;
166
-
167
412
  /** @type {(HTMLStyleElement|CSSStyleSheet)[]} */
168
413
  styles = [];
169
414
 
@@ -173,25 +418,19 @@ export default class Composition {
173
418
  /** @type {DocumentFragment} */
174
419
  stylesFragment;
175
420
 
176
- /** @type {((this:T, changes:T) => any)[]} */
177
- watchers = [];
178
-
179
421
  /**
180
- * Maintains a reference list of elements used by render target (root).
181
- * When root is garbage collected, references are released.
182
- * This includes disconnected elements.
183
- * @type {WeakMap<Element|DocumentFragment, Map<string,HTMLElement>>}
422
+ * List of IDs used by template elements
423
+ * May be needed to be removed when adding to non-DocumentFragment
424
+ *
425
+ * @type {string[]}
184
426
  */
185
- referenceCache = new WeakMap();
427
+ allIds = [];
186
428
 
187
429
  /**
188
- * Part of interpolation phase.
189
- * Maintains a reference list of conditional elements that were removed from
190
- * `cloneable` due to default state. Used to reconstruct conditional elements
191
- * with conditional children in default state as well (unlike `interpolation`).
192
- * @type {Map<string, {element: Element, id: string, parentId: string, commentCache: WeakMap<Element|DocumentFragment,Comment>}>}
430
+ * Collection of IDs used for referencing elements
431
+ * Not meant for live DOM. Removed before attaching to document
193
432
  */
194
- conditionalElementMetadata = new Map();
433
+ temporaryIds = new Set();
195
434
 
196
435
  /** Flag set when template and styles have been interpolated */
197
436
  interpolated = false;
@@ -212,9 +451,6 @@ export default class Composition {
212
451
  yield part;
213
452
  }
214
453
  yield this.template;
215
- for (const part of this.watchers) {
216
- yield part;
217
- }
218
454
  }
219
455
 
220
456
  /**
@@ -224,8 +460,6 @@ export default class Composition {
224
460
  for (const part of parts) {
225
461
  if (typeof part === 'string') {
226
462
  this.append(generateFragment(part.trim()));
227
- } else if (typeof part === 'function') {
228
- this.watchers.push(part);
229
463
  } else if (part instanceof Composition) {
230
464
  this.append(...part);
231
465
  } else if (part instanceof DocumentFragment) {
@@ -240,257 +474,249 @@ export default class Composition {
240
474
 
241
475
  /** @param {import('./typings.js').CompositionEventListener<T>} listener */
242
476
  addCompositionEventListener(listener) {
243
- const key = listener.id ?? '';
244
- let set = this.events.get(key);
245
- if (!set) {
246
- set = new Set();
247
- this.events.set(key, set);
477
+ const key = listener.tag ?? '';
478
+ if (this.events.has(key)) {
479
+ this.events.get(key).push(listener);
480
+ } else {
481
+ this.events.set(key, [listener]);
248
482
  }
249
- set.add(listener);
250
483
  return this;
251
484
  }
252
485
 
253
486
  /**
487
+ * @param {string|symbol} tag
488
+ * @param {EventTarget} target
489
+ * @param {any} [context]
490
+ * @return {void}
491
+ */
492
+ #bindCompositionEventListeners(tag, target, context) {
493
+ if (!this.events.has(tag)) return;
494
+ for (const event of this.events.get(tag)) {
495
+ let listener;
496
+ if (event.handleEvent) {
497
+ listener = event.handleEvent;
498
+ } else if (event.deepProp.length) {
499
+ listener = deepPropFromObject(event.deepProp, this.interpolateOptions.defaults);
500
+ } else {
501
+ listener = propFromObject(event.prop, this.interpolateOptions.defaults);
502
+ }
503
+ target.addEventListener(event.type, context ? listener.bind(context) : listener, event);
504
+ }
505
+ }
506
+
507
+ /**
508
+ * TODO: Add types and clean up closure leak
254
509
  * Updates component nodes based on data.
255
510
  * Expects data in JSON Merge Patch format
511
+ *
256
512
  * @see https://www.rfc-editor.org/rfc/rfc7386
257
- * @param {DocumentFragment|ShadowRoot} root where
258
- * @param {Partial<?>} changes what
259
- * @param {any} [context] who
260
- * @param {Partial<?>} [store] If needed, where to grab extra props
261
- * @return {void}
513
+ * @template {Object} T
514
+ * @param {Partial<T>} changes what specifically
515
+ * @param {T} [data]
516
+ * @param {RenderOptions<T>} [options]
517
+ * @return {Function & {target:Element}} anchor
262
518
  */
263
- render(root, changes, context, store) {
264
- if (!this.initiallyRendered) this.initialRender(root, changes);
265
-
266
- if (!changes) return;
267
-
268
- const fnResults = new WeakMap();
269
- /** @type {WeakMap<Element, Set<string>>} */
270
- const modifiedNodes = new WeakMap();
271
-
272
- // Iterate data instead of bindings.
273
- // TODO: Avoid double iteration and flatten on-the-fly
274
- const flattened = flattenObject(changes);
275
-
276
- for (const [key, rawValue] of Object.entries(flattened)) {
277
- const entries = this.bindings.get(key);
278
- if (!entries) continue;
279
- for (const { id, node, nodeType, fn, props, negate, doubleNegate } of entries) {
280
- /* 1. Find Element */
281
-
282
- // TODO: Avoid unnecessary element creation.
283
- // If element can be fully reconstructed with internal properties,
284
- // skip recreation of element unless it actually needs to added to DOM.
285
- // Requires tracing of all properties used by conditional elements.
286
- const ref = this.getElement(root, id);
287
- if (!ref) {
288
- console.warn('Non existent id', id);
289
- continue;
290
- }
291
- if (!ref) continue;
292
- if (modifiedNodes.get(ref)?.has(node)) {
293
- // console.warn('Node already modified. Skipping', id, node);
294
- continue;
519
+ render(changes, data, options = {}) {
520
+ // console.log('render', changes, options);
521
+ if (!this.interpolated) {
522
+ this.interpolate({ defaults: data ?? changes, injections: options?.injections });
523
+ }
524
+
525
+ const instanceFragment = /** @type {DocumentFragment} */ (this.cloneable.cloneNode(true));
526
+
527
+ const target = options.target ?? instanceFragment.firstElementChild;
528
+
529
+ const isShadowRoot = target instanceof ShadowRoot;
530
+
531
+ /** @type {InitializationState} */
532
+ const initState = {
533
+ instanceFragment,
534
+ lastChildNode: null,
535
+ lastChildNodeIndex: 0,
536
+ lastElement: null,
537
+ isShadowRoot,
538
+ ranFlags: [],
539
+ comments: [],
540
+ nodes: [],
541
+ caches: this.initCache.slice(),
542
+ dirtyFlags: [],
543
+ refs: [],
544
+ options,
545
+ };
546
+
547
+ const nodes = initState.nodes;
548
+ for (const { tag, textNodes } of this.nodesToBind) {
549
+ const element = instanceFragment.getElementById(tag);
550
+ initState.refs.push(element);
551
+ nodes.push(element);
552
+ this.#bindCompositionEventListeners(tag, element, options.context);
553
+
554
+ if (!textNodes.length) continue;
555
+
556
+ let textNode = element.firstChild;
557
+ let currentIndex = 0;
558
+ for (const index of textNodes) {
559
+ while (index !== currentIndex) {
560
+ textNode = textNode.nextSibling;
561
+ currentIndex++;
295
562
  }
563
+ nodes.push(textNode);
564
+ }
565
+ }
566
+ this.#bindCompositionEventListeners('', options.context);
567
+ this.#bindCompositionEventListeners(Composition.shadowRootTag, options.context.shadowRoot, options.context);
296
568
 
297
- // if (!ref.parentElement && node !== '_if') {
298
- // if (ref.parentNode === root) {
299
- // console.debug('Offscreen? root? rendering', ref, node, ref.id, root.host.outerHTML);
300
- // } else {
301
- // console.debug('Offscreen rendering', ref, node, ref.id, root.host.outerHTML);
302
- // }
303
- // }
304
-
305
- /* 2. Compute value */
306
- let value;
307
- if (fn) {
308
- if (fnResults.has(fn)) {
309
- value = fnResults.get(fn);
310
- } else {
311
- const args = structuredClone(changes);
312
- for (const prop of props) {
313
- if (prop in flattened) continue;
314
- let lastIndexOfDot = prop.lastIndexOf('.');
315
- if (lastIndexOfDot === -1) {
316
- // console.debug('injected shallow', prop);
317
- args[prop] = store[prop];
318
- } else {
319
- // Relying on props being sorted...
320
- console.debug('need deep', prop);
321
- let entry;
322
- let propSearchKey = prop;
323
- let lastPropSearchKey = prop;
324
- while (!entry) {
325
- entry = entryFromPropName(propSearchKey, args);
326
- if (entry) {
327
- const propName = lastPropSearchKey.slice(propSearchKey.length + 1);
328
- entry[1][propName] = valueFromPropName(lastPropSearchKey, store);
329
- break;
330
- }
331
- if (lastIndexOfDot === -1) break;
332
- lastPropSearchKey = prop;
333
- propSearchKey = prop.slice(0, lastIndexOfDot);
334
- lastIndexOfDot = propSearchKey.lastIndexOf(',');
335
- }
336
- if (!entry) {
337
- console.warn('what do?');
338
- }
339
- }
340
- }
341
- value = fn.call(context, args);
342
- fnResults.set(fn, value);
569
+ for (const action of this.postInitActions) {
570
+ action.invocation(initState);
571
+ }
572
+
573
+ const draw = (changes, data) => {
574
+ let ranSearch = false;
575
+ for (const prop of this.props) {
576
+ if (!this.actionsByPropsUsed.has(prop)) continue;
577
+ if (!(prop in changes)) continue;
578
+ const actions = this.actionsByPropsUsed.get(prop);
579
+ for (const action of actions) {
580
+ ranSearch = true;
581
+ const result = executeSearch(action.search, initState, changes, data);
582
+ if (result.dirty) {
583
+ // console.log('dirty, updating from batch', initState.nodes[action.nodeIndex], 'with', result.value);
584
+ action.invocation(initState, result.value, changes, data);
343
585
  }
344
- } else {
345
- value = rawValue;
346
586
  }
347
-
348
- /* 3. Operate on value */
349
- if (doubleNegate) {
350
- value = !!value;
351
- } else if (negate) {
352
- value = !value;
587
+ }
588
+ if (!ranSearch) return;
589
+ initState.ranFlags.fill(false);
590
+ initState.dirtyFlags.fill(false);
591
+ };
592
+
593
+ if (isShadowRoot) {
594
+ options.context ??= target.host;
595
+ if ('adoptedStyleSheets' in target) {
596
+ if (this.adoptedStyleSheets.length) {
597
+ target.adoptedStyleSheets = [
598
+ ...target.adoptedStyleSheets,
599
+ ...this.adoptedStyleSheets,
600
+ ];
353
601
  }
602
+ } else if (this.stylesFragment.hasChildNodes()) {
603
+ instanceFragment.prepend(this.stylesFragment.cloneNode(true));
604
+ }
605
+ } else {
606
+ options.context ??= target;
607
+ }
354
608
 
355
- /* 4. Find Target Node */
356
- if (nodeType === Node.TEXT_NODE) {
357
- const index = (node === '#text')
358
- ? 0
359
- : Number.parseInt(node.slice('#text'.length), 10);
360
- let nodesFound = 0;
361
- for (const childNode of ref.childNodes) {
362
- if (childNode.nodeType !== Node.TEXT_NODE) continue;
363
- if (index !== nodesFound++) continue;
364
- childNode.nodeValue = value ?? '';
365
- break;
366
- }
367
- if (index > nodesFound) {
368
- console.warn('Node not found, adding?');
369
- ref.append(value);
370
- }
371
- } else if (node === '_if') {
372
- const attached = root.contains(ref);
373
- const orphaned = ref.parentElement == null && ref.parentNode !== root;
374
- const shouldShow = value !== null && value !== false;
375
- if (orphaned && ref.parentNode) {
376
- console.warn('Orphaned with parent node?', id, { attached, orphaned, shouldShow }, ref.parentNode);
377
- }
378
- if (attached !== !orphaned) {
379
- console.warn('Conditional state', id, { attached, orphaned, shouldShow });
380
- console.warn('Not attached and not orphaned. Should do nothing?', ref, ref.parentElement);
381
- }
382
- if (shouldShow) {
383
- if (orphaned) {
384
- const metadata = this.conditionalElementMetadata.get(id);
385
- if (!metadata) {
386
- console.error(id);
387
- throw new Error('Could not find conditional element metadata');
388
- }
609
+ if (changes !== this.interpolateOptions.defaults) {
610
+ // Not default, overwrite nodes
611
+ draw(changes, data);
612
+ }
389
613
 
390
- let comment = metadata.commentCache.get(root);
391
- if (!comment) {
392
- console.debug('Composition: Comment not cached, building first time');
393
- const parent = metadata.parentId
394
- ? this.getElement(root, metadata.parentId)
395
- : root;
396
- if (!parent) {
397
- console.error(id);
398
- throw new Error('Could not find reference parent!');
399
- }
400
-
401
- const commentText = `{#${id}}`;
402
- for (const child of parent.childNodes) {
403
- if (child.nodeType !== Node.COMMENT_NODE) continue;
404
- if ((/** @type {Comment} */child).nodeValue === commentText) {
405
- comment = child;
406
- break;
407
- }
408
- }
409
- metadata.commentCache.set(this, comment);
410
- }
411
- if (comment) {
412
- console.debug('Composition: Add', id, 'back', ref.outerHTML);
413
- comment.replaceWith(ref);
414
- } else {
415
- console.warn('Could not add', id, 'back to parent');
416
- }
417
- }
418
- } else if (!orphaned) {
419
- const metadata = this.conditionalElementMetadata.get(id);
420
- if (!metadata) {
421
- console.error(id);
422
- throw new Error(`Could not find conditional element metadata for ${id}`);
423
- }
424
- let comment = metadata.commentCache.get(root);
425
- if (!comment) {
426
- comment = new Comment(`{#${id}}`);
427
- metadata.commentCache.set(this, comment);
428
- }
429
- console.debug('Composition: Remove', id, ref.outerHTML);
430
- ref.replaceWith(comment);
431
- }
432
- } else if (value === false || value == null) {
433
- ref.removeAttribute(node);
434
- } else {
435
- ref.setAttribute(node, value === true ? '' : value);
614
+ if (isShadowRoot) {
615
+ target.append(instanceFragment);
616
+ }
617
+
618
+ draw.target = target;
619
+ draw.byProp = (prop, value, data) => {
620
+ if (!this.actionsByPropsUsed.has(prop)) return;
621
+ let ranSearch = false;
622
+
623
+ // Update search
624
+ if (this.searchByQuery.has(prop)) {
625
+ ranSearch = true;
626
+ const search = this.searchByQuery.get(prop);
627
+ const cachedValue = initState.caches[search.cacheIndex];
628
+ if (cachedValue === value) {
629
+ return;
436
630
  }
631
+ initState.ranFlags[search.ranFlagIndex] = true;
632
+ initState.caches[search.cacheIndex] = value;
633
+ initState.dirtyFlags[search.dirtyIndex] = true;
634
+ }
437
635
 
438
- /* 5. Mark Node as modified */
439
- let set = modifiedNodes.get(ref);
440
- if (!set) {
441
- set = new Set();
442
- modifiedNodes.set(ref, set);
636
+ let changes;
637
+ const actions = this.actionsByPropsUsed.get(prop);
638
+ for (const action of actions) {
639
+ if (action.search.query === prop) {
640
+ action.invocation(initState, value);
641
+ } else {
642
+ changes ??= { [prop]: value };
643
+ data ??= changes;
644
+ ranSearch = true;
645
+ const result = executeSearch(action.search, initState, changes, data);
646
+ if (result.dirty) {
647
+ // console.debug('dirty, updating by prop', prop, initState.nodes[action.nodeIndex], 'with', result.value);
648
+ action.invocation(initState, result.value, changes, data);
649
+ }
443
650
  }
444
- set.add(node);
445
651
  }
446
- }
652
+
653
+ if (!ranSearch) return;
654
+ initState.ranFlags.fill(false);
655
+ initState.dirtyFlags.fill(false);
656
+ };
657
+ draw.state = initState;
658
+ return draw;
447
659
  }
448
660
 
449
661
  /**
450
662
  * @param {Attr|Text} node
451
663
  * @param {Element} element
452
- * @param {Object} [defaults]
664
+ * @param {InterpolateOptions} [options]
453
665
  * @param {string} [parsedValue]
454
- * @return {boolean} Remove node
666
+ * @return {true|undefined} remove node
455
667
  */
456
- #interpolateNode(node, element, defaults, parsedValue) {
668
+ #interpolateNode(node, element, options, parsedValue) {
457
669
  const { nodeValue, nodeName, nodeType } = node;
458
670
 
671
+ /** @type {Attr} */
672
+ let attr;
673
+ /** @type {Text} */
674
+ let text;
675
+ if (nodeType === Node.ATTRIBUTE_NODE) {
676
+ attr = /** @type {Attr} */ (node);
677
+ } else {
678
+ text = /** @type {Text} */ (node);
679
+ }
680
+
681
+ // Get template strings(s) in node if not passed
459
682
  if (parsedValue == null) {
460
- if (!nodeValue) return false;
683
+ if (!nodeValue) return;
461
684
  const trimmed = nodeValue.trim();
462
- if (!trimmed) return false;
463
- if (nodeType === Node.ATTRIBUTE_NODE) {
464
- if (trimmed[0] !== '{') return false;
685
+ if (!trimmed) return;
686
+ if (attr) {
687
+ if (trimmed[0] !== '{') return;
465
688
  const { length } = trimmed;
466
- if (trimmed[length - 1] !== '}') return false;
689
+ if (trimmed[length - 1] !== '}') return;
467
690
  parsedValue = trimmed.slice(1, -1);
691
+ // TODO: Support segmented attribute values
468
692
  } else {
469
693
  // Split text node into segments
470
694
  // TODO: Benchmark indexOf pre-check vs regex
471
695
 
472
696
  const segments = trimmed.split(STRING_INTERPOLATION_REGEX);
473
- if (segments.length < 3) return false;
697
+ if (segments.length < 3) return;
474
698
  if (segments.length === 3 && !segments[0] && !segments[2]) {
475
699
  parsedValue = segments[1];
476
700
  } else {
477
- segments.forEach((segment, index) => {
701
+ for (const [index, segment] of segments.entries()) {
478
702
  // is even = is template string
479
703
  if (index % 2) {
480
- const newNode = new Text();
481
- node.before(newNode);
482
- this.#interpolateNode(newNode, element, defaults, segment);
483
- } else {
484
- if (!segment) return; // blank
485
- node.before(segment);
704
+ const newNode = createEmptyTextNode();
705
+ text.before(newNode);
706
+ this.#interpolateNode(newNode, element, options, segment);
707
+ } else if (segment) {
708
+ text.before(segment);
486
709
  }
487
- });
488
- // node.remove();
710
+ }
711
+ // eslint-disable-next-line consistent-return
489
712
  return true;
490
713
  }
491
714
  }
492
715
  }
493
716
 
717
+ // Check mutations
718
+
719
+ const query = parsedValue;
494
720
  const negate = parsedValue[0] === '!';
495
721
  let doubleNegate = false;
496
722
  if (negate) {
@@ -504,171 +730,508 @@ export default class Composition {
504
730
  let isEvent;
505
731
  let textNodeIndex;
506
732
 
507
- if (nodeType === Node.TEXT_NODE) {
733
+ if (text) {
508
734
  // eslint-disable-next-line unicorn/consistent-destructuring
509
- if (element !== node.parentElement) {
735
+ if (element !== text.parentElement) {
510
736
  console.warn('mismatch?');
511
- element = node.parentElement;
737
+ element = text.parentElement;
512
738
  }
513
739
  textNodeIndex = 0;
514
- let prev = node;
740
+ /** @type {ChildNode} */
741
+ let prev = text;
515
742
  while ((prev = prev.previousSibling)) {
516
- if (prev.nodeType === Node.TEXT_NODE) {
517
- textNodeIndex++;
518
- }
743
+ textNodeIndex++;
519
744
  }
520
745
  } else {
521
746
  // @ts-ignore Skip cast
522
747
  // eslint-disable-next-line unicorn/consistent-destructuring
523
- if (element !== node.ownerElement) {
748
+ if (element !== attr.ownerElement) {
524
749
  console.warn('mismatch?');
525
- element = node.ownerElement;
750
+ element = attr.ownerElement;
526
751
  }
527
752
  if (nodeName.startsWith('on')) {
528
753
  // Do not interpolate inline event listeners
529
- if (nodeName[2] !== '-') return false;
530
- isEvent = true;
754
+ const hyphenIndex = nodeName.indexOf('-');
755
+ if (hyphenIndex === -1) return;
756
+ isEvent = hyphenIndex === 2;
531
757
  }
532
758
  }
533
759
 
534
- const id = identifierFromElement(element, true);
535
-
536
760
  if (isEvent) {
761
+ element.removeAttribute(nodeName);
762
+ const tag = this.#tagElement(element);
537
763
  const eventType = nodeName.slice(3);
538
764
  const [, flags, type] = eventType.match(/^([*1~]+)?(.*)$/);
539
- const options = {
765
+
766
+ let handleEvent;
767
+ /** @type {string} */
768
+ let prop;
769
+ /** @type {string[]} */
770
+ let deepProp = [];
771
+ if (parsedValue.startsWith('#')) {
772
+ handleEvent = inlineFunctions.get(parsedValue).fn;
773
+ } else {
774
+ const parsedProps = parsedValue.split('.');
775
+ if (parsedProps.length === 1) {
776
+ prop = parsedValue;
777
+ deepProp = [];
778
+ } else {
779
+ prop = parsedProps[0];
780
+ deepProp = parsedProps;
781
+ }
782
+ }
783
+
784
+ this.addCompositionEventListener({
785
+ tag,
786
+ type,
787
+ handleEvent,
788
+ prop,
789
+ deepProp,
540
790
  once: flags?.includes('1'),
541
791
  passive: flags?.includes('~'),
542
792
  capture: flags?.includes('*'),
543
- };
793
+ });
544
794
 
545
- element.removeAttribute(nodeName);
795
+ return;
796
+ }
546
797
 
547
- let set = this.events.get(id);
548
- if (!set) {
549
- set = new Set();
550
- this.events.set(id, set);
551
- }
552
- if (parsedValue.startsWith('#')) {
553
- set.add({ type, handleEvent: inlineFunctions.get(parsedValue).fn, ...options });
798
+ /** @type {RenderGraphSearch} */
799
+ let search;
800
+
801
+ if (this.searchByQuery.has(query)) {
802
+ search = this.searchByQuery.get(query);
803
+ } else {
804
+ // Has subquery?
805
+ const subquery = parsedValue;
806
+ const isSubquery = subquery !== query;
807
+ /** @type {RenderGraphSearch} */
808
+ let subSearch;
809
+ if (isSubquery && this.searchByQuery.has(subquery)) {
810
+ subSearch = this.searchByQuery.get(subquery);
554
811
  } else {
555
- set.add({ type, prop: parsedValue, ...options });
556
- }
557
- return false;
558
- }
812
+ // Construct subsearch, even is not subquery.
813
+ /** @type {Function} */
814
+ let expression;
815
+ /** @type {string[]} */
816
+ let propsUsed;
817
+ /** @type {string[][]} */
818
+ let deepPropsUsed;
819
+ let defaultValue;
820
+ let prop;
821
+ let deepProp;
822
+ let invocation;
823
+
824
+ let inlineFunctionOptions;
825
+ // Is Inline Function?
826
+ if (parsedValue.startsWith('#')) {
827
+ inlineFunctionOptions = inlineFunctions.get(parsedValue);
828
+ if (!inlineFunctionOptions) {
829
+ console.warn(`Invalid interpolation value: ${parsedValue}`);
830
+ return;
831
+ }
832
+ expression = inlineFunctionOptions.fn;
833
+ invocation = searchWithExpression;
834
+ if (inlineFunctionOptions.props) {
835
+ // console.log('This function has already been called. Reuse props', inlineFunctionOptions, this);
836
+ propsUsed = inlineFunctionOptions.props;
837
+ deepPropsUsed = inlineFunctionOptions.deepProps;
838
+ defaultValue = inlineFunctionOptions.defaultValue ?? null;
839
+ } else {
840
+ defaultValue = inlineFunctionOptions.fn;
841
+ }
842
+ } else {
843
+ defaultValue = null;
844
+ if (options?.defaults) {
845
+ defaultValue = deepPropFromObject(parsedValue.split('.'), options.defaults) ?? null;
846
+ }
847
+ if (defaultValue == null && options?.injections) {
848
+ defaultValue = valueFromPropName(parsedValue, options.injections);
849
+ console.log('default value from injection', parsedValue, { defaultValue });
850
+ }
851
+ }
559
852
 
560
- /** @type {Function} */
561
- let fn;
562
- /** @type {Set<string>} */
563
- let props;
564
-
565
- /** @type {any} */
566
- let defaultValue;
567
- let inlineFunctionOptions;
568
- // Is Inline Function?
569
- if (parsedValue.startsWith('#')) {
570
- inlineFunctionOptions = inlineFunctions.get(parsedValue);
571
- if (!inlineFunctionOptions) {
572
- console.warn(`Invalid interpolation value: ${parsedValue}`);
573
- return false;
853
+ if (!propsUsed) {
854
+ if (typeof defaultValue === 'function') {
855
+ // Value must be reinterpolated and function observed
856
+ const observeResult = observeFunction.call(this, defaultValue, options?.defaults, options?.injections);
857
+ const uniqueProps = new Set([
858
+ ...observeResult.props.this,
859
+ ...observeResult.props.args[0],
860
+ ...observeResult.props.args[1],
861
+ ]);
862
+ const uniqueDeepProps = new Set([
863
+ ...observeResult.deepPropStrings.this,
864
+ ...observeResult.deepPropStrings.args[0],
865
+ ]);
866
+ expression = defaultValue;
867
+ defaultValue = observeResult.defaultValue;
868
+ propsUsed = [...uniqueProps];
869
+ deepPropsUsed = [...uniqueDeepProps].map((deepPropString) => deepPropString.split('.'));
870
+ invocation = searchWithExpression;
871
+ // console.log(this.static.name, fn.name || parsedValue, combinedSet);
872
+ } else {
873
+ // property binding
874
+ const parsedProps = parsedValue.split('.');
875
+ if (parsedProps.length === 1) {
876
+ prop = parsedValue;
877
+ propsUsed = [prop];
878
+ invocation = searchWithProp;
879
+ } else {
880
+ propsUsed = [parsedProps[0]];
881
+ deepProp = parsedProps;
882
+ deepPropsUsed = [parsedProps];
883
+ invocation = searchWithDeepProp;
884
+ }
885
+
886
+ // TODO: Rewrite property as deep with array index?
887
+ }
888
+ }
889
+
890
+ if (inlineFunctionOptions) {
891
+ inlineFunctionOptions.defaultValue = defaultValue;
892
+ inlineFunctionOptions.props = propsUsed;
893
+ inlineFunctionOptions.deepProps = deepPropsUsed;
894
+ }
895
+ subSearch = {
896
+ cacheIndex: this.#interpolationState.cacheIndex++,
897
+ dirtyIndex: this.#interpolationState.dirtyIndex++,
898
+ ranFlagIndex: this.#interpolationState.ranFlagIndex++,
899
+ query: subquery,
900
+ defaultValue,
901
+ subSearch: null,
902
+ prop,
903
+ propsUsed,
904
+ deepProp,
905
+ deepPropsUsed,
906
+ invocation,
907
+ expression,
908
+ };
909
+ this.addSearch(subSearch);
574
910
  }
575
- if (inlineFunctionOptions.props) {
576
- console.log('This function has already been called. Reuse props', inlineFunctionOptions, this);
577
- props = inlineFunctionOptions.props;
578
- defaultValue = inlineFunctionOptions.defaultValue ?? null;
911
+ if (isSubquery) {
912
+ search = {
913
+ cacheIndex: this.#interpolationState.cacheIndex++,
914
+ dirtyIndex: this.#interpolationState.dirtyIndex++,
915
+ ranFlagIndex: this.#interpolationState.ranFlagIndex++,
916
+ query,
917
+ subSearch,
918
+ negate,
919
+ doubleNegate,
920
+ prop: subSearch.prop,
921
+ deepProp: subSearch.deepProp,
922
+ propsUsed: subSearch.propsUsed,
923
+ deepPropsUsed: subSearch.deepPropsUsed,
924
+ defaultValue: doubleNegate ? !!subSearch.defaultValue
925
+ : (negate ? !subSearch.defaultValue : subSearch.defaultValue),
926
+ invocation(value) {
927
+ if (this.doubleNegate) return !!value;
928
+ if (this.negate) return !value;
929
+ console.warn('Unknown query mutation', this.query);
930
+ return value;
931
+ },
932
+ };
933
+ this.addSearch(search);
579
934
  } else {
580
- defaultValue = inlineFunctionOptions.fn;
935
+ // Store as search instead
936
+ search = subSearch;
581
937
  }
582
- } else {
583
- defaultValue = valueFromPropName(parsedValue, defaults);
584
938
  }
585
939
 
586
- if (!props) {
587
- if (typeof defaultValue === 'function') {
588
- // Value must be reinterpolated and function observed
589
- const observeResult = observeFunction.call(this, defaultValue, defaults);
590
- fn = defaultValue;
591
- defaultValue = observeResult.defaultValue;
592
- props = observeResult.props;
593
- // console.log(this.static.name, fn.name || parsedValue, combinedSet);
940
+ // Tag
941
+ let tag;
942
+ let subnode = null;
943
+ let defaultValue = search.defaultValue;
944
+ if (text) {
945
+ text.data = defaultValue;
946
+ subnode = textNodeIndex;
947
+ } else if (nodeName === 'mdw-if') {
948
+ tag = this.#tagElement(element);
949
+ element.removeAttribute(nodeName);
950
+ defaultValue = defaultValue != null && defaultValue !== false;
951
+ } else {
952
+ subnode = nodeName;
953
+ if (nodeName === 'id' || defaultValue == null || defaultValue === false) {
954
+ element.removeAttribute(nodeName);
594
955
  } else {
595
- props = new Set([parsedValue]);
956
+ element.setAttribute(nodeName, defaultValue === true ? '' : defaultValue);
596
957
  }
597
958
  }
598
959
 
599
- if (typeof defaultValue === 'symbol') {
600
- console.warn(': Invalid binding:', parsedValue);
601
- defaultValue = null;
602
- }
960
+ tag ??= this.#tagElement(element);
603
961
 
604
- if (doubleNegate) {
605
- defaultValue = !!defaultValue;
606
- } else if (negate) {
607
- defaultValue = !defaultValue;
962
+ // Node entry
963
+ let nodeEntry = this.#interpolationState.nodeEntry;
964
+ if (!nodeEntry || nodeEntry.tag !== tag) {
965
+ nodeEntry = {
966
+ tag,
967
+ textNodes: [],
968
+ };
969
+ this.#interpolationState.nodeEntry = nodeEntry;
970
+ this.nodesToBind.push(nodeEntry);
971
+ this.#interpolationState.nodeIndex++;
608
972
  }
609
973
 
610
- if (inlineFunctionOptions) {
611
- inlineFunctionOptions.defaultValue = defaultValue;
612
- inlineFunctionOptions.props = props;
613
- }
974
+ /** @type {RenderGraphAction} */
975
+ let action;
614
976
 
615
- // Bind
616
- const parsedNodeName = textNodeIndex ? nodeName + textNodeIndex : nodeName;
617
- const entry = { id, node: parsedNodeName, fn, props, nodeType, defaultValue, negate, doubleNegate };
618
- for (const prop of props) {
619
- let set = this.bindings.get(prop);
620
- if (!set) {
621
- set = new Set();
622
- this.bindings.set(prop, set);
977
+ // Node Action
978
+ if (text) {
979
+ nodeEntry.textNodes.push(textNodeIndex);
980
+
981
+ this.#interpolationState.nodeIndex++;
982
+ action = {
983
+ nodeIndex: this.#interpolationState.nodeIndex,
984
+ invocation: writeDOMText,
985
+ defaultValue,
986
+ search,
987
+ };
988
+ } else if (subnode) {
989
+ action = {
990
+ nodeIndex: this.#interpolationState.nodeIndex,
991
+ attrName: subnode,
992
+ defaultValue,
993
+ invocation: writeDOMAttribute,
994
+ search,
995
+ };
996
+ } else {
997
+ action = {
998
+ nodeIndex: this.#interpolationState.nodeIndex,
999
+ commentIndex: this.#interpolationState.commentIndex++,
1000
+ defaultValue,
1001
+ invocation: writeDOMElementAttachedState,
1002
+ search,
1003
+ };
1004
+ if (!defaultValue) {
1005
+ this.postInitActions.push({
1006
+ ...action,
1007
+ invocation: writeDOMHideElementOnInit,
1008
+ });
623
1009
  }
624
- set.add(entry);
625
1010
  }
626
1011
 
627
- // Mutate
1012
+ this.addAction(action);
1013
+ this.tagsWithBindings.add(tag);
1014
+ }
628
1015
 
629
- if (nodeType === Node.TEXT_NODE) {
630
- node.nodeValue = defaultValue ?? '';
631
- } else if (nodeName === '_if') {
632
- element.removeAttribute(nodeName);
633
- if (defaultValue == null || defaultValue === false) {
634
- // If default state is removed, mark for removal
635
- return true;
1016
+ /**
1017
+ * @param {Element} element
1018
+ * @return {string}
1019
+ */
1020
+ #tagElement(element) {
1021
+ let id = element.id;
1022
+ if (id) {
1023
+ if (!this.allIds.includes(id)) {
1024
+ this.allIds.push(id);
636
1025
  }
637
- } else if (defaultValue == null || defaultValue === false) {
638
- element.removeAttribute(nodeName);
639
1026
  } else {
640
- element.setAttribute(nodeName, defaultValue === true ? '' : defaultValue);
1027
+ id = generateUID();
1028
+ this.temporaryIds.add(id);
1029
+ this.allIds.push(id);
1030
+ element.id = id;
1031
+ }
1032
+ return id;
1033
+ }
1034
+
1035
+ /**
1036
+ * TODO: Subtemplating lacks optimization, though functional.
1037
+ * - Would benefit from custom type handler for arrays
1038
+ * to avoid multi-iteration change-detection.
1039
+ * - Could benefit from debounced/throttled render
1040
+ * - Consider remap of {item.prop} as {array[index].prop}
1041
+ *
1042
+ * @param {Element} element
1043
+ * @param {InterpolateOptions} options
1044
+ * @return {?Composition<?>}
1045
+ */
1046
+ #interpolateIterable(element, options) {
1047
+ // TODO: Microbenchmark element.attributes
1048
+ const forAttr = element.getAttribute('mdw-for');
1049
+ const trimmed = forAttr?.trim();
1050
+ if (!trimmed) {
1051
+ console.warn('Malformed mdw-for found at', element);
1052
+ return null;
1053
+ }
1054
+
1055
+ if (trimmed[0] !== '{') {
1056
+ console.warn('Malformed mdw-for found at', element);
1057
+ return null;
1058
+ }
1059
+ const { length } = trimmed;
1060
+ if (trimmed[length - 1] !== '}') {
1061
+ console.warn('Malformed mdw-for found at', element);
1062
+ return null;
641
1063
  }
642
- return false;
1064
+ const parsedValue = trimmed.slice(1, -1);
1065
+ const [valueName, iterableName] = parsedValue.split(/\s+of\s+/);
1066
+ element.removeAttribute('mdw-for');
1067
+ // Create a new composition targetting element as root
1068
+
1069
+ const elementAnchor = document.createElement('template');
1070
+ element.replaceWith(elementAnchor);
1071
+ const tag = this.#tagElement(elementAnchor);
1072
+ // console.log('tagging placeholder element with', elementAnchor, tag);
1073
+
1074
+ let nodeEntry = this.#interpolationState.nodeEntry;
1075
+ if (!nodeEntry || nodeEntry.tag !== tag) {
1076
+ nodeEntry = {
1077
+ tag,
1078
+ textNodes: [],
1079
+ };
1080
+ this.#interpolationState.nodeEntry = nodeEntry;
1081
+ this.nodesToBind.push(nodeEntry);
1082
+ this.#interpolationState.nodeIndex++;
1083
+ console.log('adding node entry', tag, this.#interpolationState.nodeIndex);
1084
+ }
1085
+
1086
+ const newComposition = new Composition();
1087
+ newComposition.template.append(element);
1088
+ // Move uninterpolated element to new composition template.
1089
+ const injections = {
1090
+ ...options.injections,
1091
+ [valueName]: null,
1092
+ index: null,
1093
+ };
1094
+
1095
+ const propsUsed = [iterableName];
1096
+ /** @type {RenderGraphSearch} */
1097
+ const search = {
1098
+ cacheIndex: this.#interpolationState.cacheIndex++,
1099
+ dirtyIndex: this.#interpolationState.dirtyIndex++,
1100
+ ranFlagIndex: this.#interpolationState.ranFlagIndex++,
1101
+ propsUsed,
1102
+ deepPropsUsed: [[iterableName]],
1103
+ defaultValue: {},
1104
+ invocation(state, changes, data) {
1105
+ // Return unique to always specify dirty
1106
+ return {};
1107
+ },
1108
+ };
1109
+
1110
+ /** @type {RenderGraphAction} */
1111
+ const action = {
1112
+ defaultValue: null,
1113
+ nodeIndex: this.#interpolationState.nodeIndex,
1114
+ search,
1115
+ commentIndex: this.#interpolationState.commentIndex++,
1116
+ injections,
1117
+ invocation(state, value, changes, data) {
1118
+ if (!newComposition.adapter) {
1119
+ // console.log({ state.options });
1120
+ const instanceAnchorElement = state.nodes[this.nodeIndex];
1121
+ const anchorNode = createEmptyComment();
1122
+ // Avoid leak
1123
+ state.nodes[this.commentIndex] = anchorNode;
1124
+ instanceAnchorElement.replaceWith(anchorNode);
1125
+ newComposition.adapter = new CompositionAdapter({
1126
+ anchorNode,
1127
+ composition: newComposition,
1128
+ renderOptions: {
1129
+ target: null,
1130
+ context: state.options.context,
1131
+ store: state.options.store,
1132
+ injections: this.injections,
1133
+ },
1134
+ });
1135
+ }
1136
+ const { adapter } = newComposition;
1137
+ const iterable = (data ?? state.options.store)[iterableName];
1138
+ // Remove oversized
1139
+ if (!iterable || iterable.length === 0) {
1140
+ adapter.removeEntries();
1141
+ return;
1142
+ }
1143
+ const changeList = changes[iterableName];
1144
+ const innerChanges = { ...changes };
1145
+ const needTargetAll = newComposition.props.some((prop) => prop !== iterableName && prop in changes);
1146
+
1147
+ adapter.startBatch();
1148
+ if (!needTargetAll && !Array.isArray(changeList)) {
1149
+ const iterator = Array.isArray(changeList) ? changeList.entries() : Object.entries(changeList);
1150
+ // console.log('changeList render', iterator);
1151
+ for (const [key, change] of iterator) {
1152
+ if (key === 'length') continue;
1153
+ if (change === null) {
1154
+ // console.warn('null?', 'remove?', key);
1155
+ continue;
1156
+ }
1157
+ const index = (+key);
1158
+ const resource = iterable[index];
1159
+ innerChanges[valueName] = change;
1160
+ this.injections[valueName] = resource;
1161
+ this.injections.index = index;
1162
+
1163
+ adapter.renderData(index, innerChanges, data, resource, change);
1164
+ }
1165
+ } else {
1166
+ if (!changeList) {
1167
+ delete innerChanges[valueName];
1168
+ }
1169
+ // console.log('full array render', iterable);
1170
+ for (const [index, resource] of iterable.entries()) {
1171
+ let change;
1172
+ if (changeList) {
1173
+ // console.warn('full array render has changeList?', changeList);
1174
+ if (!needTargetAll && !(index in changeList)) {
1175
+ console.warn('huh?');
1176
+ continue;
1177
+ }
1178
+ change = changeList[index];
1179
+ if (change === null) {
1180
+ // console.warn('remove?');
1181
+ continue;
1182
+ }
1183
+ innerChanges[valueName] = change;
1184
+ }
1185
+ this.injections[valueName] = resource;
1186
+ this.injections.index = index;
1187
+
1188
+ adapter.renderData(index, innerChanges, data, resource, change);
1189
+ // adapter.renderIndex(index, innerChanges, data, resource);
1190
+ }
1191
+ }
1192
+ adapter.stopBatch();
1193
+
1194
+ adapter.removeEntries(iterable.length);
1195
+ },
1196
+
1197
+ };
1198
+
1199
+ newComposition.interpolate({
1200
+ defaults: options.defaults,
1201
+ injections,
1202
+ });
1203
+
1204
+ propsUsed.push(...newComposition.props);
1205
+ this.addSearch(search);
1206
+ this.addAction(action);
1207
+ this.tagsWithBindings.add(tag);
1208
+ // console.log('adding', iterable, 'bind to', this);
1209
+ // this.addBinding(iterable, entry);
1210
+ return newComposition;
643
1211
  }
644
1212
 
645
1213
  /**
646
- * @param {Object} [defaults]
1214
+ * @param {InterpolateOptions} [options]
647
1215
  */
648
- interpolate(defaults) {
1216
+ interpolate(options) {
1217
+ this.interpolateOptions = options;
649
1218
  // console.log('Template', [...this.template.children].map((child) => child.outerHTML).join('\n'));
650
1219
 
651
1220
  // Copy template before working on it
652
1221
  // Store into `cloneable` to split later into `interpolation`
653
1222
  this.cloneable = /** @type {DocumentFragment} */ (this.template.cloneNode(true));
654
1223
 
655
- /**
656
- * Track elements to be removed before using for cloning
657
- * @type {Element[]}
658
- */
659
- const removalList = [];
660
-
661
1224
  const TREE_WALKER_FILTER = 5; /* NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT */
662
1225
 
663
1226
  const treeWalker = document.createTreeWalker(this.cloneable, TREE_WALKER_FILTER);
1227
+ /** Note: `node` and treeWalker.currentNode may deviate */
664
1228
  let node = treeWalker.nextNode();
665
1229
  while (node) {
666
1230
  /** @type {Element} */
667
1231
  let element = null;
668
- let removeElement = false;
669
1232
  switch (node.nodeType) {
670
1233
  case Node.ELEMENT_NODE:
671
- element = node;
1234
+ element = /** @type {Element} */ (node);
672
1235
  if (element instanceof HTMLTemplateElement) {
673
1236
  node = treeWalker.nextSibling();
674
1237
  continue;
@@ -689,27 +1252,26 @@ export default class Composition {
689
1252
  node = treeWalker.nextSibling();
690
1253
  continue;
691
1254
  }
692
- for (const attr of [...element.attributes].reverse()) {
693
- if (attr.nodeName === '_if') {
694
- // Ensure elements to be removed has identifiable parent
695
- const id = identifierFromElement(element, true);
696
- const parentId = element.parentElement
697
- ? identifierFromElement(element.parentElement, true)
698
- : null;
699
- this.conditionalElementMetadata.set(id, {
700
- element,
701
- id,
702
- parentId,
703
- commentCache: new WeakMap(),
704
- });
1255
+
1256
+ if (element.hasAttribute('mdw-for')) {
1257
+ node = treeWalker.nextSibling();
1258
+ this.#interpolateIterable(element, options);
1259
+ } else {
1260
+ const idAttr = element.attributes.id;
1261
+ if (idAttr) {
1262
+ this.#interpolateNode(idAttr, element, options);
1263
+ this.#tagElement(element);
1264
+ }
1265
+ for (const attr of [...element.attributes].reverse()) {
1266
+ if (attr.nodeName === 'id') continue; // Already handled
1267
+ this.#interpolateNode(attr, element, options);
705
1268
  }
706
- removeElement ||= this.#interpolateNode(attr, element, defaults);
707
1269
  }
708
1270
 
709
1271
  break;
710
1272
  case Node.TEXT_NODE:
711
1273
  element = node.parentNode;
712
- if (this.#interpolateNode(/** @type {Text} */ (node), element, defaults)) {
1274
+ if (this.#interpolateNode(/** @type {Text} */ (node), element, options)) {
713
1275
  const nextNode = treeWalker.nextNode();
714
1276
  node.remove();
715
1277
  node = nextNode;
@@ -720,29 +1282,9 @@ export default class Composition {
720
1282
  default:
721
1283
  throw new Error(`Unexpected node type: ${node.nodeType}`);
722
1284
  }
723
- if (removeElement) {
724
- removalList.push(element);
725
- }
726
1285
  node = treeWalker.nextNode();
727
1286
  }
728
1287
 
729
- // Split into `interpolation` before removing elements
730
- /** @type {DocumentFragment} */
731
- this.interpolation = /** @type {DocumentFragment} */ (this.cloneable.cloneNode(true));
732
-
733
- // console.debug('Interpolated', [...this.interpolation.children].map((child) => child.outerHTML).join('\n'));
734
-
735
- // Remove elements from `cloneable` and place comment placeholders
736
- // Remove in reverse so conditionals within conditionals are properly isolated
737
- for (const element of [...removalList].reverse()) {
738
- const { id } = element;
739
- element.replaceWith(new Comment(`{#${id}}`));
740
- }
741
-
742
- for (const watcher of this.watchers) {
743
- this.bindWatcher(watcher, defaults);
744
- }
745
-
746
1288
  if ('adoptedStyleSheets' in document) {
747
1289
  this.adoptedStyleSheets = [
748
1290
  ...generateCSSStyleSheets(this.styles),
@@ -754,235 +1296,50 @@ export default class Composition {
754
1296
  );
755
1297
  }
756
1298
 
757
- this.interpolated = true;
1299
+ this.props = [...this.actionsByPropsUsed.keys()];
758
1300
 
759
- // console.log('Cloneable', [...this.cloneable.children].map((child) => child.outerHTML).join('\n'));
760
- }
761
-
762
- /**
763
- * Updates component nodes based on data
764
- * Expects data in JSON Merge Patch format
765
- * @see https://www.rfc-editor.org/rfc/rfc7386
766
- * @param {DocumentFragment|ShadowRoot} root where
767
- * @param {Partial<?>} data what
768
- * @return {void}
769
- */
770
- initialRender(root, data) {
771
- if (!this.interpolated) this.interpolate(data);
772
-
773
- if ('adoptedStyleSheets' in root) {
774
- root.adoptedStyleSheets = [
775
- ...root.adoptedStyleSheets,
776
- ...this.adoptedStyleSheets,
777
- ];
778
- } else if (root instanceof ShadowRoot) {
779
- root.append(this.stylesFragment.cloneNode(true));
780
- } else {
781
- // TODO: Support document styles
782
- // console.warn('Cannot apply styles to singular element');
783
- }
784
-
785
- root.append(this.cloneable.cloneNode(true));
786
-
787
- // console.log('Initial render', [...root.children].map((child) => child.outerHTML).join('\n'));
788
-
789
- /** @type {EventTarget} */
790
- const rootEventTarget = root instanceof ShadowRoot ? root.host : root;
791
- // Bind events in reverse order to support stopImmediatePropagation
792
- for (const [id, events] of [...this.events].reverse()) {
793
- // Prepare all event listeners first
794
- for (const entry of [...events].reverse()) {
795
- let listener = entry.listener;
796
- if (!listener) {
797
- if (root instanceof ShadowRoot) {
798
- listener = entry.handleEvent ?? valueFromPropName(entry.prop, data);
799
- if (id) {
800
- // Wrap to retarget this
801
- listener = buildShadowRootChildListener(listener);
802
- }
803
- // Cache and reuse
804
- entry.listener = listener;
805
- // console.log('caching listener', entry);
806
- } else {
807
- throw new TypeError('Anonymous event listeners cannot be used in templates');
808
- // console.warn('creating new listener', entry);
809
- // listener = entry.handleEvent ?? ((event) => {
810
- // valueFromPropName(entry.prop, data)(event);
811
- // });
812
- }
813
- }
814
- const eventTarget = id ? root.getElementById(id) : rootEventTarget;
815
- if (!eventTarget) {
816
- // Element is not available yet. Bind on reference
817
- console.debug('Composition: Skip bind events for', id);
818
- continue;
819
- }
820
- eventTarget.addEventListener(entry.type, listener, entry);
1301
+ for (const id of this.allIds) {
1302
+ if (!this.tagsWithBindings.has(id)) {
1303
+ this.nodesToBind.push({
1304
+ tag: id,
1305
+ textNodes: [],
1306
+ });
821
1307
  }
822
1308
  }
823
1309
 
824
- this.initiallyRendered = true;
825
- }
1310
+ this.tags = this.nodesToBind.map((n) => n.tag);
826
1311
 
827
- /**
828
- * @param {string} id
829
- * @param {Element} element
830
- */
831
- attachEventListeners(id, element) {
832
- const events = this.events.get(id);
833
- if (events) {
834
- console.debug('attaching events for', id);
835
- } else {
836
- // console.log('no events for', id);
837
- return;
838
- }
839
- for (const entry of [...this.events.get(id)].reverse()) {
840
- const { listener } = entry;
841
- if (!listener) {
842
- throw new Error('Template must be interpolated before attaching events');
843
- }
844
- element.addEventListener(entry.type, listener, entry);
845
- }
846
- }
1312
+ this.interpolated = true;
847
1313
 
848
- /**
849
- * @param {Element|DocumentFragment} root
850
- * @return {Map<string,Element>}
851
- */
852
- getReferences(root) {
853
- let references = this.referenceCache.get(root);
854
- if (!references) {
855
- references = new Map();
856
- this.referenceCache.set(root, references);
857
- }
858
- return references;
1314
+ // console.log('Cloneable', [...this.cloneable.children].map((child) => child.outerHTML).join('\n'));
859
1315
  }
860
1316
 
861
1317
  /**
862
- * @param {ShadowRoot|DocumentFragment} root
863
- * @param {string} id
864
- * @return {Element}
1318
+ * @param {RenderGraphSearch} search
1319
+ * @return {RenderGraphSearch}
865
1320
  */
866
- getElement(root, id) {
867
- const references = this.getReferences(root);
868
- let element = references.get(id);
869
- if (element) {
870
- // console.log('Returning from cache', id);
871
- return element;
872
- }
873
- if (element === null) return null; // Cached null response
874
-
875
- // Undefined
876
-
877
- // console.log('Search in DOM', id);
878
- element = root.getElementById(id);
879
-
880
- if (element) {
881
- // console.log('Found in DOM', id);
882
- references.set(id, element);
883
- return element;
1321
+ addSearch(search) {
1322
+ this.searches.push(search);
1323
+ if (search.query) {
1324
+ this.searchByQuery.set(search.query, search);
1325
+ this.initCache[search.cacheIndex] = search.defaultValue;
884
1326
  }
885
-
886
- // Element not in DOM means child of conditional element
887
- /** @type {Element} */
888
- let anchorElement;
889
-
890
- // Check if element is conditional
891
- let cloneTarget = this.conditionalElementMetadata.get(id)?.element;
892
-
893
- if (!cloneTarget) {
894
- // Check if element even exists in interpolation
895
- // Check interpolation (full-tree) first
896
- const interpolatedElement = this.interpolation.getElementById(id);
897
- if (!interpolatedElement) {
898
- console.warn('Not in full-tree', id);
899
- // Cache not in full composition
900
- references.set(id, null);
901
- return null;
902
- }
903
- // Iterate backgrounds until closest conditional element
904
- // const anchorElementId = this.template.getElementById(id).closest('[_if]').id;
905
- // anchorElement = this.references.get(anchorElementId) this.interpolation.getElementById(anchorElementId).cloneNode(true);
906
- let parentElement = interpolatedElement;
907
- while ((parentElement = parentElement.parentElement) != null) {
908
- const parentId = parentElement.id;
909
- if (!parentId) {
910
- console.warn('Parent does not have ID!');
911
- cloneTarget = parentElement;
912
- continue;
913
- }
914
-
915
- // Parent already referenced
916
- const referencedParent = references.get(parentId);
917
- if (referencedParent) {
918
- // Element may have been removed without ever tree-walking
919
- console.debug('Parent already referenced', parentId, '>', id);
920
- anchorElement = referencedParent;
921
- break;
922
- }
923
-
924
- const liveElement = root.getElementById(parentId);
925
- if (liveElement) {
926
- console.warn('Parent in DOM and not referenced', parentId, '>', id);
927
- // Parent already in DOM. Cache reference
928
- references.set(parentId, liveElement);
929
- anchorElement = referencedParent;
930
- break;
931
- }
932
-
933
- const conditionalParent = this.conditionalElementMetadata.get(parentId)?.element;
934
- if (conditionalParent) {
935
- console.debug('Found parent conditional element', parentId, '>', id);
936
- cloneTarget = conditionalParent;
937
- break;
938
- }
939
-
940
- cloneTarget = parentElement;
941
- }
942
- }
943
-
944
- anchorElement ??= /** @type {Element} */ (cloneTarget.cloneNode(true));
945
-
946
- // Iterate downwards and cache all references
947
- let node = anchorElement;
948
- const iterator = document.createTreeWalker(anchorElement, NodeFilter.SHOW_ELEMENT);
949
- do {
950
- const nodeIdentifier = node.id;
951
- if (!element && nodeIdentifier === id) {
952
- element = node;
953
- }
954
-
955
- if (nodeIdentifier) {
956
- // console.debug('Caching element', nodeIdentifier);
957
- references.set(nodeIdentifier, node);
958
- if (cloneTarget) {
959
- // Attach events regardless of DOM state.
960
- // EventTargets should still fire even if not part of live document
961
- this.attachEventListeners(id, element);
962
- }
963
- } else {
964
- console.warn('Could not cache node', node);
965
- }
966
- } while ((node = iterator.nextNode()));
967
- return element;
1327
+ return search;
968
1328
  }
969
1329
 
970
1330
  /**
971
- * @param {*} fn
972
- * @param {any} defaults
973
- * @return {boolean} reusable
1331
+ * @param {RenderGraphAction} action
1332
+ * @return {RenderGraphAction}
974
1333
  */
975
- bindWatcher(fn, defaults) {
976
- const { props, defaultValue, reusable } = observeFunction(fn, defaults);
977
- const entry = { fn, props, defaultValue };
978
- for (const prop of props) {
979
- let set = this.bindings.get(prop);
980
- if (!set) {
981
- set = new Set();
982
- this.bindings.set(prop, set);
1334
+ addAction(action) {
1335
+ this.actions.push(action);
1336
+ for (const prop of action.search.propsUsed) {
1337
+ if (this.actionsByPropsUsed.has(prop)) {
1338
+ this.actionsByPropsUsed.get(prop).push(action);
1339
+ } else {
1340
+ this.actionsByPropsUsed.set(prop, [action]);
983
1341
  }
984
- set.add(entry);
985
1342
  }
986
- return reusable;
1343
+ return action;
987
1344
  }
988
1345
  }