@jinntec/fore 2.9.0 → 3.1.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 (42) hide show
  1. package/README.md +0 -2
  2. package/dist/fore-dev.js +9505 -6057
  3. package/dist/fore.js +9218 -5996
  4. package/index.js +1 -0
  5. package/package.json +4 -2
  6. package/src/DependentXPathQueries.js +37 -2
  7. package/src/ForeElementMixin.js +64 -38
  8. package/src/actions/fx-delete.js +424 -73
  9. package/src/actions/fx-insert.js +472 -73
  10. package/src/actions/fx-setattribute.js +5 -5
  11. package/src/actions/fx-setvalue.js +11 -9
  12. package/src/authoring-check.js +231 -0
  13. package/src/fore.js +32 -77
  14. package/src/functions/registerFunction.js +65 -20
  15. package/src/fx-bind.js +128 -73
  16. package/src/fx-fore.js +653 -219
  17. package/src/fx-instance.js +145 -143
  18. package/src/fx-model.js +292 -102
  19. package/src/fx-speech.js +268 -0
  20. package/src/fx-submission.js +246 -135
  21. package/src/fx-var.js +28 -4
  22. package/src/json/JSONDomFacade.js +84 -0
  23. package/src/json/JSONLens.js +67 -0
  24. package/src/json/JSONNode.js +248 -0
  25. package/src/json/lensFromNode.js +5 -0
  26. package/src/modelitem.js +16 -2
  27. package/src/tools/fx-action-log.js +1 -1
  28. package/src/ui/UIElement.js +16 -2
  29. package/src/ui/abstract-control.js +14 -7
  30. package/src/ui/fx-case.js +3 -1
  31. package/src/ui/fx-control.js +4 -2
  32. package/src/ui/fx-group.js +1 -1
  33. package/src/ui/fx-items.js +26 -32
  34. package/src/ui/fx-output.js +8 -38
  35. package/src/ui/fx-repeat-attributes.js +1 -1
  36. package/src/ui/fx-repeat.js +683 -247
  37. package/src/ui/fx-repeatitem.js +16 -1
  38. package/src/ui/repeat-base.js +9 -5
  39. package/src/withDraggability.js +0 -1
  40. package/src/xpath-evaluation.js +1763 -740
  41. package/src/xpath-path.js +274 -24
  42. package/src/xpath-util.js +92 -46
@@ -11,8 +11,6 @@ import { getPath } from '../xpath-path.js';
11
11
  import { FxModel } from '../fx-model.js';
12
12
  import { FxBind } from '../fx-bind.js';
13
13
 
14
- // import {DependencyNotifyingDomFacade} from '../DependencyNotifyingDomFacade';
15
-
16
14
  /**
17
15
  * `fx-repeat`
18
16
  *
@@ -21,12 +19,7 @@ import { FxBind } from '../fx-bind.js';
21
19
  * Template is a standard HTML `<template>` element. Once instanciated the template
22
20
  * is moved to the shadowDOM of the repeat for safe re-use.
23
21
  *
24
- *
25
- *
26
22
  * @customElement
27
- * @demo demo/todo.html
28
- *
29
- * todo: it should be seriously be considered to extend FxContainer instead but needs refactoring first.
30
23
  * @extends {ForeElementMixin}
31
24
  */
32
25
  export class FxRepeat extends withDraggability(UIElement, false) {
@@ -69,124 +62,245 @@ export class FxRepeat extends withDraggability(UIElement, false) {
69
62
  this.repeatSize = 0;
70
63
  this.attachShadow({ mode: 'open', delegatesFocus: true });
71
64
  this.opNum = 0; // global number of operations
65
+
66
+ this.handleInsertHandler = null;
67
+ this.handleDeleteHandler = null;
68
+
69
+ // Tracks ModelItems we observe due to JSON lens lookups inside predicate expressions
70
+ // (e.g. instance('data')?ui?query). Needed so the repeat refreshes when the query changes.
71
+ this._jsonPredicateDeps = new Set();
72
+ this._jsonPredicateDepsObserved = false;
73
+
74
+ // Flag used to suppress "programmatic index changed" notifications when setIndex()
75
+ // is called as a direct reaction to a repeatitem's item-changed event.
76
+ this._settingIndexFromItemChanged = false;
77
+ }
78
+
79
+ // ------------------------------------------------------------
80
+ // JSON ref helpers (for routing insert/delete events correctly)
81
+ // ------------------------------------------------------------
82
+
83
+ _stripJsonRefToContainer(ref) {
84
+ let s = String(ref || '').trim();
85
+ if (!s) return '';
86
+
87
+ // If this is a repeat nodeset ref like: instance('data')?movies?*[...]
88
+ // strip everything from "?*" onward => instance('data')?movies
89
+ const starPos = s.indexOf('?*');
90
+ if (starPos >= 0) s = s.slice(0, starPos).trim();
91
+
92
+ // Also strip trailing predicates if someone wrote ...?movies[...]
93
+ // (not common for lens refs, but keep it safe)
94
+ s = s.replace(/\[[\s\S]*\]\s*$/g, '').trim();
95
+
96
+ return s;
97
+ }
98
+
99
+ _inferArrayKeyFromRef() {
100
+ const r = String(this.ref || this.getAttribute('ref') || '').trim();
101
+
102
+ // instance('data')?movies?*...
103
+ let m = r.match(/\?([^?\[\]]+)\?\*\s*/);
104
+ if (m) return m[1];
105
+
106
+ // instance('data')?movies (no ?*)
107
+ m = r.match(/\?([^?\[\]]+)\s*$/);
108
+ if (m) return m[1];
109
+
110
+ return null;
111
+ }
112
+
113
+ _sameJsonContainer(detailRef) {
114
+ const myContainer = this._stripJsonRefToContainer(this.ref);
115
+ const evContainer = this._stripJsonRefToContainer(detailRef);
116
+ if (!myContainer || !evContainer) return false;
117
+ return myContainer === evContainer;
118
+ }
119
+
120
+ _matchesJsonParent(detail) {
121
+ // Fallback routing based on insertedParent / insertedNodes.parent
122
+ const parent =
123
+ detail?.insertedParent ||
124
+ detail?.insertedNodes?.parent ||
125
+ detail?.insertedNodes?.insertedParent ||
126
+ null;
127
+
128
+ if (!parent || !parent.__jsonlens__) return false;
129
+
130
+ const myKey = this._inferArrayKeyFromRef();
131
+ if (myKey && String(parent.keyOrIndex) !== String(myKey)) return false;
132
+
133
+ // If instance is known on both sides, ensure it matches
134
+ const myInstanceId = XPathUtil.resolveInstance(this, this.ref);
135
+ if (myInstanceId && parent.instanceId && String(myInstanceId) !== String(parent.instanceId)) {
136
+ return false;
137
+ }
138
+
139
+ return true;
72
140
  }
73
141
 
74
142
  connectedCallback() {
75
143
  super.connectedCallback();
76
144
  this.template = this.querySelector('template');
77
145
 
78
- // console.log('connectedCallback',this);
79
- // this.display = window.getComputedStyle(this, null).getPropertyValue("display");
80
146
  this.ref = this.getAttribute('ref');
81
147
  this.dependencies.addXPath(this.ref);
82
- // this.ref = this._getRef();
83
- // console.log('### fx-repeat connected ', this.id);
148
+
84
149
  this.addEventListener('item-changed', e => {
150
+ // IMPORTANT: when *we* emit item-changed from the repeat (programmatic setIndex),
151
+ // we must not react to it (would recurse).
152
+ if (e && e.target === this) return;
153
+ if (e?.detail?.source === 'repeat') return;
154
+
85
155
  const { item } = e.detail;
86
- this.setIndex(item.index);
156
+ this._settingIndexFromItemChanged = true;
157
+ try {
158
+ this.setIndex(item.index);
159
+ } finally {
160
+ this._settingIndexFromItemChanged = false;
161
+ }
87
162
  });
88
163
 
89
- // Listen for insertion events
164
+ // ----------------
165
+ // INSERT handler
166
+ // ----------------
90
167
  this.handleInsertHandler = event => {
91
168
  const { detail } = event;
92
169
  const myForeId = this.getOwnerForm().id;
93
- if (myForeId !== detail.foreId) {
170
+ if (myForeId !== detail.foreId) return;
171
+
172
+ const fore = this.getOwnerForm();
173
+
174
+ // Detect JSON insert (robust)
175
+ const insertedParent = detail?.insertedParent;
176
+ const insertedNode = detail?.insertedNodes;
177
+ const isJson =
178
+ !!detail?.isJson ||
179
+ !!insertedParent?.__jsonlens__ ||
180
+ !!insertedNode?.__jsonlens__ ||
181
+ !!insertedNode?.parent?.__jsonlens__;
182
+
183
+ if (isJson) {
184
+ // IMPORTANT FIX:
185
+ // The old code compared detail.ref strictly to a computed container ref.
186
+ // For repeats like instance('data')?movies?*[predicate] the container is "instance('data')?movies"
187
+ // while detail.ref is usually exactly that container. But if we keep the predicate in this.ref,
188
+ // a strict string compare will FAIL and the insert never updates the DOM -> stays at 12.
189
+ //
190
+ // We accept the event if either:
191
+ // 1) detail.ref matches our container (predicate stripped), OR
192
+ // 2) insertedParent / insertedNode.parent matches our array key + instance.
193
+ const okByRef = this._sameJsonContainer(detail?.ref);
194
+ const okByParent = this._matchesJsonParent(detail);
195
+
196
+ if (!okByRef && !okByParent) return;
197
+
198
+ this._handleJsonInserted(detail);
94
199
  return;
95
200
  }
96
- // todo: early out if this.ref does not match the ref of the inserted node. Avoid re-evaluating the nodeset
97
- // if (this.ref !== detail.ref) return;
98
201
 
99
- console.log('insert catched', detail);
100
-
101
- // Step 1: Refresh/re-evaluate the nodeset
202
+ // ----------------
203
+ // XML insert: keep existing behavior
204
+ // ----------------
102
205
  const oldNodesetLength = this.nodeset.length;
103
206
  this._evalNodeset();
104
207
  const newNodesetLength = this.nodeset.length;
105
- if (oldNodesetLength === newNodesetLength) {
106
- return;
107
- }
208
+ if (oldNodesetLength === newNodesetLength) return;
209
+
210
+ const inserted = detail.insertedNodes;
211
+ const insertionIndex = this.nodeset.indexOf(inserted) + 1; // 1-based
108
212
 
109
- /**
110
- * @type {number}
111
- */
112
- // const insertionIndex = detail.index;
113
- /**
114
- * The newly inserted node. TODO: handle multiple?
115
- * @type {Node}
116
- */
117
- const insertedNode = detail.insertedNodes;
118
- const insertionIndex = this.nodeset.indexOf(insertedNode) + 1;
119
- // Step 2: Get current repeat items and create a new item
120
- /**
121
- * @type {import('./fx-repeatitem.js').FxRepeatitem[]}
122
- */
123
213
  const repeatItems = Array.from(
124
214
  this.querySelectorAll(
125
215
  ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
126
216
  ),
127
217
  );
128
218
 
129
- // todo: search fx-bind elements with same nodeset as this repeat - if present update modelItem instead of creating one
130
219
  const newRepeatItem = this._createNewRepeatItem();
131
220
 
132
- // Step 3: Insert the new repeatItem at the correct position
133
- const beforeNode = repeatItems[insertionIndex - 1] ?? null; // Null appends by default
221
+ const beforeNode = repeatItems[insertionIndex - 1] ?? null;
134
222
  this.insertBefore(newRepeatItem, beforeNode);
135
223
  newRepeatItem.index = insertionIndex;
136
224
  this._initVariables(newRepeatItem);
137
225
 
138
- // Step 4: Assign the inserted nodeset to the new `repeatItem`
139
- newRepeatItem.nodeset = detail.insertedNodes;
226
+ newRepeatItem.nodeset = inserted;
140
227
 
141
- // Update all the indices following here
142
228
  for (let i = insertionIndex - 1; i < repeatItems.length; ++i) {
143
- const sibling = repeatItems[i];
144
- // TODO: handle the next ones
145
- sibling.index += 1;
229
+ repeatItems[i].index += 1;
146
230
  }
147
231
 
148
- this.setIndex(insertionIndex); // sets attribute + applies repeat-index + refresh
232
+ this.setIndex(insertionIndex);
149
233
 
150
- // Generate the parent `modelItem` for the new repeat item
151
234
  this.opNum++;
152
- const parentModelItem = FxBind.createModelItem(
153
- this.ref,
154
- detail.insertedNodes,
155
- newRepeatItem,
156
- this.opNum,
157
- );
235
+ let parentModelItem = FxBind.createModelItem(this.ref, inserted, newRepeatItem, this.opNum);
236
+ // IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
237
+ // Always keep using the returned instance to avoid "ghost" ModelItems that still notify.
238
+ parentModelItem = this.getModel().registerModelItem(parentModelItem);
158
239
  newRepeatItem.modelItem = parentModelItem;
159
240
 
160
- this.getModel().registerModelItem(parentModelItem);
161
-
162
- // Step 5: Create modelItems recursively for child elements
163
241
  this._createModelItemsRecursively(newRepeatItem, parentModelItem);
164
- // Step 6: Notify and refresh the UI
165
- this.getOwnerForm().scanForNewTemplateExpressionsNextRefresh();
166
- this.getOwnerForm().addToBatchedNotifications(newRepeatItem);
242
+
243
+ fore.scanForNewTemplateExpressionsNextRefresh();
244
+ fore.addToBatchedNotifications(newRepeatItem);
167
245
  };
246
+
247
+ // ----------------
248
+ // DELETE handler
249
+ // ----------------
168
250
  this.handleDeleteHandler = event => {
169
- console.log('delete catched', event);
170
251
  const { detail } = event;
171
- if (!detail || !detail.deletedNodes) {
252
+ if (!detail || !detail.deletedNodes || detail.deletedNodes.length === 0) return;
253
+
254
+ const fore = this.getOwnerForm();
255
+ const myForeId = fore?.id;
256
+ if (detail.foreId && myForeId !== detail.foreId) return;
257
+
258
+ const deletedNodes = Array.from(detail.deletedNodes || []);
259
+ const first = deletedNodes[0];
260
+
261
+ const isJson =
262
+ !!detail.isJson ||
263
+ !!first?.__jsonlens__ ||
264
+ !!first?.parent?.__jsonlens__ ||
265
+ deletedNodes.some(n => n?.__jsonlens__ || n?.parent?.__jsonlens__);
266
+
267
+ if (isJson) {
268
+ // Route by parent array container, NOT by detail.ref string.
269
+ const parent =
270
+ (detail.parent && Array.isArray(detail.parent.value) && detail.parent) ||
271
+ (first?.parent && Array.isArray(first.parent.value) ? first.parent : null);
272
+
273
+ if (!parent) return;
274
+
275
+ const myKey = this._inferArrayKeyFromRef();
276
+
277
+ if (myKey && String(parent.keyOrIndex) !== String(myKey)) return;
278
+
279
+ if (
280
+ detail.instanceId &&
281
+ parent.instanceId &&
282
+ String(detail.instanceId) !== String(parent.instanceId)
283
+ ) {
284
+ return;
285
+ }
286
+
287
+ this._handleJsonDeleted(detail);
172
288
  return;
173
289
  }
174
290
 
175
- // Remove corresponding repeat items for deleted nodes
291
+ // XML delete: keep existing behavior
176
292
  detail.deletedNodes.forEach(node => {
177
293
  this.handleDelete(node);
178
- // this.removeRepeatItemForNode(node);
179
294
  });
180
- this.getOwnerForm().addToBatchedNotifications(this);
295
+ fore?.addToBatchedNotifications?.(this);
181
296
  };
182
- // inside connectedCallback()
297
+
183
298
  document.addEventListener('insert', this.handleInsertHandler, true);
184
299
  document.addEventListener('deleted', this.handleDeleteHandler, true);
185
300
 
186
- // if (this.getOwnerForm().lazyRefresh) {
187
- /**
188
- * @type {MutationRecord[]}
189
- */
301
+ // ----------------
302
+ // Mutation observer (XML only)
303
+ // ----------------
190
304
  let bufferedMutationRecords = [];
191
305
  let debouncedOnMutations = null;
192
306
  this.mutationObserver = new MutationObserver(mutations => {
@@ -196,7 +310,6 @@ export class FxRepeat extends withDraggability(UIElement, false) {
196
310
  debouncedOnMutations = null;
197
311
  const records = bufferedMutationRecords;
198
312
  bufferedMutationRecords = [];
199
- const shouldRefresh = false;
200
313
  for (const mutation of records) {
201
314
  if (mutation.type === 'childList') {
202
315
  const added = mutation.addedNodes[0];
@@ -214,12 +327,7 @@ export class FxRepeat extends withDraggability(UIElement, false) {
214
327
  this.getOwnerForm().registerLazyElement(this);
215
328
 
216
329
  const style = `
217
- :host{
218
- }
219
- .fade-out-bottom {
220
- -webkit-animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
221
- animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
222
- }
330
+ :host{ }
223
331
  .fade-out-bottom {
224
332
  -webkit-animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
225
333
  animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
@@ -228,19 +336,103 @@ export class FxRepeat extends withDraggability(UIElement, false) {
228
336
  const html = `
229
337
  <slot name="header"></slot>
230
338
  <slot></slot>
231
- <slot name="footer"></slot>
232
- `;
339
+ `;
340
+
233
341
  this.shadowRoot.innerHTML = `
234
- <style>
235
- ${style}
236
- </style>
237
- ${html}
238
- `;
342
+ <style>${style}</style>
343
+ ${html}
344
+ `;
345
+ }
346
+
347
+ /**
348
+ * JSON insert (incremental):
349
+ * - re-evaluate nodeset once to get authoritative post-insert ordering
350
+ * - insert one new repeatitem at the correct position (using detail.index)
351
+ * - rebind only shifted repeatitems (pos..end) and refresh them
352
+ */
353
+ _handleJsonInserted(detail) {
354
+ const fore = this.getOwnerForm();
239
355
 
240
- // this.init();
356
+ const repeatItems = () =>
357
+ Array.from(
358
+ this.querySelectorAll(
359
+ ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
360
+ ),
361
+ );
362
+
363
+ // 1) Determine insertion index (1-based) from the event.
364
+ // Do NOT use indexOf(insertedNodes) for JSON.
365
+ let insertionIndex1 = Number(detail.index);
366
+ if (!Number.isFinite(insertionIndex1) || insertionIndex1 < 1) {
367
+ const ki = detail.insertedNodes?.keyOrIndex;
368
+ if (typeof ki === 'number') insertionIndex1 = ki + 1;
369
+ }
370
+ if (!Number.isFinite(insertionIndex1) || insertionIndex1 < 1) insertionIndex1 = 1;
371
+
372
+ // 2) Re-evaluate nodeset AFTER mutation to get correct order.
373
+ const oldLen = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
374
+ this._evalNodeset();
375
+ const newLen = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
376
+ if (newLen === oldLen) return;
377
+
378
+ // Clamp
379
+ insertionIndex1 = Math.max(1, Math.min(insertionIndex1, newLen));
380
+ const pos0 = insertionIndex1 - 1;
381
+
382
+ // 3) Insert DOM row: set nodeset/index BEFORE inserting into DOM.
383
+ const before = repeatItems();
384
+ const beforeNode = before[pos0] ?? null;
385
+
386
+ const newRepeatItem = this._createNewRepeatItem();
387
+ newRepeatItem.index = pos0 + 1;
388
+ this._initVariables(newRepeatItem);
389
+ newRepeatItem.nodeset = this.nodeset[pos0];
390
+
391
+ this.insertBefore(newRepeatItem, beforeNode);
392
+
393
+ // Ensure it gets initialized/rendered
394
+ fore.registerLazyElement(newRepeatItem);
395
+ if (fore.createNodes) {
396
+ fore.initData(newRepeatItem);
397
+ }
398
+ fore.scanForNewTemplateExpressionsNextRefresh();
399
+ fore.addToBatchedNotifications(newRepeatItem);
400
+
401
+ // 4) Rebind shifted rows (pos0+1..end) and refresh only those.
402
+ const after = repeatItems();
403
+
404
+ for (let i = pos0 + 1; i < after.length; i++) {
405
+ const ri = after[i];
406
+ ri.index = i + 1;
407
+
408
+ const newNode = this.nodeset[i];
409
+ if (ri.nodeset !== newNode) {
410
+ ri.nodeset = newNode;
411
+ if (fore.createNodes) fore.initData(ri);
412
+ }
413
+
414
+ fore.addToBatchedNotifications(ri);
415
+ }
416
+
417
+ // Select inserted row
418
+ this.setIndex(pos0 + 1);
241
419
  }
242
420
 
243
421
  disconnectedCallback() {
422
+ // Ensure UIElement cleanup runs (removes observer for primary binding, etc.)
423
+ if (super.disconnectedCallback) super.disconnectedCallback();
424
+
425
+ // Remove observers that were added for predicate dependencies
426
+ if (this._jsonPredicateDeps && this._jsonPredicateDeps.size) {
427
+ for (const mi of this._jsonPredicateDeps) {
428
+ if (mi && typeof mi.removeObserver === 'function') {
429
+ mi.removeObserver(this);
430
+ }
431
+ }
432
+ this._jsonPredicateDeps.clear();
433
+ }
434
+ this._jsonPredicateDepsObserved = false;
435
+
244
436
  document.removeEventListener('deleted', this.handleDeleteHandler, true);
245
437
  document.removeEventListener('insert', this.handleInsertHandler, true);
246
438
  }
@@ -254,13 +446,27 @@ export class FxRepeat extends withDraggability(UIElement, false) {
254
446
  }
255
447
 
256
448
  setIndex(index) {
257
- // console.log('new repeat index ', index);
258
449
  this.index = index;
450
+
259
451
  const rItems = this.querySelectorAll(':scope > fx-repeatitem');
260
- this.applyIndex(rItems[this.index - 1]);
452
+ const selected = rItems[this.index - 1];
261
453
 
262
- // trying to do without
263
- // this.getOwnerForm().refresh({ reason: 'index-function', elementLocalnamesWithChanges: [] });
454
+ this.applyIndex(selected);
455
+
456
+ // If setIndex is called programmatically (insert/delete), we must notify dependents
457
+ // (fx-group/fx-control/fx-output with index('repeatId') in ref).
458
+ //
459
+ // When setIndex is invoked as a reaction to a repeatitem click/focus,
460
+ // the repeatitem already dispatched item-changed and dependents already react.
461
+ if (!this._settingIndexFromItemChanged) {
462
+ this.dispatchEvent(
463
+ new CustomEvent('item-changed', {
464
+ composed: false,
465
+ bubbles: false,
466
+ detail: { item: selected || null, index: this.index, source: 'repeat' },
467
+ }),
468
+ );
469
+ }
264
470
  }
265
471
 
266
472
  applyIndex(repeatItem) {
@@ -283,42 +489,35 @@ export class FxRepeat extends withDraggability(UIElement, false) {
283
489
  }
284
490
 
285
491
  _createModelItemsRecursively(parentNode, parentModelItem) {
286
- const parentWithDewey = parentModelItem?.path || null; // e.g. $default/AllowanceCharge[2]_1
287
- const parentBase = parentWithDewey ? parentWithDewey.replace(/_\d+$/, '') : null; // e.g. $default/AllowanceCharge[2]
492
+ const parentWithDewey = parentModelItem?.path || null;
288
493
 
289
- // Robust Dewey rewrite that tolerates $inst vs instance('inst') forms
290
494
  const __applyDeweyRewrite = mi => {
291
495
  if (!mi || typeof mi.path !== 'string' || !parentModelItem?.path) return;
292
496
 
293
- const pWith = parentModelItem.path; // e.g. $default/AllowanceCharge[2]_1 or instance('default')/AllowanceCharge[2]_1
497
+ const pWith = parentModelItem.path;
294
498
  const opMatch = pWith.match(/_(\d+)$/);
295
499
  if (!opMatch) return;
296
500
  const op = opMatch[1];
297
501
 
298
- // Normalize to $name/ and strip _n on parent; normalize child for prefix test only
299
502
  const toDollar = s => s.replace(/^instance\('([^']+)'\)\//, (_m, g1) => `$${g1}/`);
300
- const parentBaseNorm = toDollar(pWith).replace(/_\d+$/, ''); // $default/AllowanceCharge[2]
503
+ const parentBaseNorm = toDollar(pWith).replace(/_\d+$/, '');
301
504
  const childNorm = toDollar(mi.path);
302
505
 
303
- if (!childNorm.startsWith(parentBaseNorm)) return; // unrelated subtree
506
+ if (!childNorm.startsWith(parentBaseNorm)) return;
304
507
 
305
- // Preserve original style of child's instance prefix
306
508
  const childUsesInstanceFn = /^instance\('/.test(mi.path);
307
509
  const parentBaseInChildStyle = childUsesInstanceFn
308
510
  ? parentBaseNorm.replace(/^\$([A-Za-z0-9_-]+)\//, `instance('$1')/`)
309
511
  : parentBaseNorm;
310
512
 
311
- // If already suffixed for this parent, nothing to do
312
513
  if (mi.path.startsWith(`${parentBaseInChildStyle}_`)) return;
313
514
 
314
- // Inject _op immediately after the parent base segment
315
515
  mi.path = `${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`;
316
516
  };
317
517
 
318
518
  Array.from(parentNode.children).forEach(child => {
319
519
  const nextParentMI = parentModelItem;
320
520
 
321
- // Skip native/embedded widgets that may carry a 'ref' but are UI only
322
521
  const isWidgetEl =
323
522
  child &&
324
523
  ((child.classList && child.classList.contains('widget')) ||
@@ -329,20 +528,19 @@ export class FxRepeat extends withDraggability(UIElement, false) {
329
528
  if (!isWidgetEl && child.hasAttribute('ref')) {
330
529
  const ref = child.getAttribute('ref').trim();
331
530
  if (ref && ref !== '.') {
332
- // Evaluate the FULL ref once — this yields the terminal (last) node(s)
333
531
  let node = evaluateXPath(ref, parentModelItem.node, this);
334
532
  if (Array.isArray(node)) node = node[0];
335
533
 
336
534
  if (node) {
337
535
  let modelItem = this.getModel().getModelItem(node);
338
536
  if (!modelItem) {
339
- // Create a ModelItem only for the final node; children never get their own opNum
340
537
  modelItem = FxBind.createModelItem(ref, node, child, null);
341
538
  modelItem.parentModelItem = parentModelItem;
342
- this.getModel().registerModelItem(modelItem);
539
+ // IMPORTANT: keep using the canonical instance returned by registerModelItem.
540
+ // Otherwise a throwaway ModelItem can leak into observer graphs and be notified.
541
+ modelItem = this.getModel().registerModelItem(modelItem);
343
542
  }
344
543
 
345
- // Always apply Dewey rewrite (handles both $inst and instance('inst') forms)
346
544
  __applyDeweyRewrite(modelItem);
347
545
 
348
546
  child.nodeset = node;
@@ -351,36 +549,81 @@ export class FxRepeat extends withDraggability(UIElement, false) {
351
549
  }
352
550
  }
353
551
 
354
- // Recurse into non-widget subtrees
355
552
  if (!isWidgetEl) this._createModelItemsRecursively(child, nextParentMI);
356
553
  });
357
554
  }
358
555
 
359
- /**
360
- * Removes the repeat item corresponding to a deleted node.
361
- * Cleans up its observers and notifies the parent form.
362
- * @param {Node} node - The deleted node
363
- */
364
- removeRepeatItemForNode(node) {
365
- const index = this.nodeset.indexOf(node);
366
- if (index === -1) return;
556
+ _handleJsonDeleted(detail) {
557
+ const fore = this.getOwnerForm();
367
558
 
368
- const repeatItem = this.querySelector(`fx-repeatitem:nth-of-type(${index + 1})`);
369
- if (repeatItem) {
370
- this.removeChild(repeatItem);
371
- this.getOwnerForm().addToBatchedNotifications(this);
559
+ const repeatItems = () =>
560
+ Array.from(
561
+ this.querySelectorAll(
562
+ ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
563
+ ),
564
+ );
565
+
566
+ let indices0 = Array.isArray(detail.deletedIndexes0)
567
+ ? detail.deletedIndexes0.slice()
568
+ : Array.from(detail.deletedNodes || [])
569
+ .map(n => (n && typeof n.keyOrIndex === 'number' ? n.keyOrIndex : -1))
570
+ .filter(i => i >= 0);
571
+
572
+ indices0 = Array.from(new Set(indices0)).sort((a, b) => b - a);
573
+ if (indices0.length === 0) return;
574
+
575
+ let currentIndex1 = Number(this.getAttribute('index') || this.index || 1);
576
+ if (!Number.isFinite(currentIndex1) || currentIndex1 < 1) currentIndex1 = 1;
577
+
578
+ const deletedIdx1Asc = indices0.map(i0 => i0 + 1).sort((a, b) => a - b);
579
+ let nextIndex1 = currentIndex1;
580
+ for (const d1 of deletedIdx1Asc) {
581
+ if (nextIndex1 > d1) nextIndex1 -= 1;
582
+ }
583
+
584
+ const before = repeatItems();
585
+ for (const idx0 of indices0) {
586
+ const itemEl = before[idx0];
587
+ if (!itemEl) continue;
588
+
589
+ try {
590
+ fore?.unRegisterLazyElement?.(itemEl);
591
+ } catch (_e) {}
592
+
593
+ itemEl.remove();
594
+ }
595
+
596
+ this._evalNodeset();
597
+
598
+ const start0 = Math.min(...indices0);
599
+ const after = repeatItems();
600
+
601
+ for (let i = start0; i < after.length; i++) {
602
+ const ri = after[i];
603
+ ri.index = i + 1;
604
+
605
+ const newNode = Array.isArray(this.nodeset) ? this.nodeset[i] : null;
606
+ if (ri.nodeset !== newNode) {
607
+ ri.nodeset = newNode;
608
+ if (fore?.createNodes) fore.initData(ri);
609
+ }
610
+
611
+ fore?.addToBatchedNotifications?.(ri);
612
+ }
613
+
614
+ const newLen = after.length;
615
+ if (newLen === 0) {
616
+ this.setAttribute('index', '0');
617
+ this.index = 0;
618
+ this._removeIndexMarker?.();
619
+ return;
372
620
  }
373
621
 
374
- // Remove the node from the nodeset
375
- this.nodeset.splice(index, 1);
622
+ nextIndex1 = Math.max(1, Math.min(nextIndex1, newLen));
623
+ this.setIndex(nextIndex1);
376
624
  }
377
625
 
378
626
  handleDelete(deleted) {
379
- console.log('handleDelete', deleted);
380
- // grab the current repeat items (tweak selector if yours differs)
381
- /**
382
- * @type {import('./fx-repeatitem.js').FxRepeatitem[]}
383
- */
384
627
  const items = Array.from(
385
628
  this.querySelectorAll(
386
629
  ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
@@ -396,24 +639,20 @@ export class FxRepeat extends withDraggability(UIElement, false) {
396
639
  const itemToRemove = items[indexToRemove];
397
640
  itemToRemove.remove();
398
641
 
399
- // If the list is now empty, clear selection (0). Adjust if you prefer 1-based only.
400
642
  const newLength = this.querySelectorAll(
401
643
  ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
402
644
  ).length;
403
645
 
404
- let nextIndex = indexToRemove + 1; // 1-based index at the deleted slot
646
+ let nextIndex = indexToRemove + 1;
405
647
  if (newLength === 0) {
406
- nextIndex = 0; // nothing left; clear selection
648
+ nextIndex = 0;
407
649
  } else if (nextIndex > newLength) {
408
- nextIndex = newLength; // deleted the last one; move to new last
650
+ nextIndex = newLength;
409
651
  }
410
652
 
411
653
  this.setIndex(nextIndex);
412
654
  }
413
655
 
414
- /**
415
- * @returns {import('./fx-repeatitem.js').FxRepeatitem}
416
- */
417
656
  _createNewRepeatItem() {
418
657
  const newItem = document.createElement('fx-repeatitem');
419
658
 
@@ -428,13 +667,7 @@ export class FxRepeat extends withDraggability(UIElement, false) {
428
667
  }
429
668
 
430
669
  init() {
431
- // ### there must be a single 'template' child
432
- // console.log('##### repeat init ', this.id);
433
- // if(!this.inited) this.init();
434
- // does not use this.evalInContext as it is expecting a nodeset instead of single node
435
670
  this._evalNodeset();
436
- // console.log('##### ',this.id, this.nodeset);
437
-
438
671
  this._initTemplate();
439
672
  this._initRepeatItems();
440
673
 
@@ -442,17 +675,268 @@ export class FxRepeat extends withDraggability(UIElement, false) {
442
675
  this.inited = true;
443
676
  }
444
677
 
445
- /**
446
- * repeat has no own modelItems
447
- * @private
448
- */
678
+ _observeJsonPredicateDependencies(contextNode) {
679
+ if (this._jsonPredicateDepsObserved) return;
680
+
681
+ const ref = String(this.ref || this.getAttribute('ref') || '').trim();
682
+ if (!ref || !ref.includes('[') || !ref.includes('?')) return;
683
+
684
+ const model = this.getModel && this.getModel();
685
+ if (!model) return;
686
+
687
+ // Collect predicate bodies [...]
688
+ const predicates = [];
689
+ const predRe = /\[([\s\S]+?)\]/g;
690
+ let pm;
691
+ while ((pm = predRe.exec(ref)) !== null) {
692
+ if (pm[1]) predicates.push(pm[1]);
693
+ }
694
+ if (predicates.length === 0) return;
695
+
696
+ const isBoundary = ch =>
697
+ ch === undefined ||
698
+ ch === null ||
699
+ /\s/.test(ch) ||
700
+ ch === ',' ||
701
+ ch === ')' ||
702
+ ch === ']' ||
703
+ ch === '+' ||
704
+ ch === '-' ||
705
+ ch === '*' ||
706
+ ch === '=' ||
707
+ ch === '>' ||
708
+ ch === '<' ||
709
+ ch === '!' ||
710
+ ch === '|' ||
711
+ ch === '&';
712
+
713
+ const lookups = new Set();
714
+
715
+ const readInstanceLensAt = (src, start) => {
716
+ if (!src.slice(start).match(/^instance\s*\(/)) return null;
717
+
718
+ let j = start;
719
+ let inS = false;
720
+ let inD = false;
721
+ let depth = 0;
722
+
723
+ while (j < src.length) {
724
+ const ch = src[j];
725
+
726
+ if (ch === "'" && !inD) {
727
+ inS = !inS;
728
+ j += 1;
729
+ continue;
730
+ }
731
+ if (ch === '"' && !inS) {
732
+ inD = !inD;
733
+ j += 1;
734
+ continue;
735
+ }
736
+ if (inS || inD) {
737
+ j += 1;
738
+ continue;
739
+ }
740
+
741
+ if (ch === '(') depth += 1;
742
+ else if (ch === ')') {
743
+ depth -= 1;
744
+ if (depth === 0) break;
745
+ }
746
+ j += 1;
747
+ }
748
+
749
+ if (j >= src.length) return null;
750
+
751
+ let k = j + 1;
752
+ while (k < src.length && /\s/.test(src[k])) k += 1;
753
+ if (src[k] !== '?') return null;
754
+
755
+ k += 1;
756
+ let bracketDepth = 0;
757
+ inS = false;
758
+ inD = false;
759
+
760
+ while (k < src.length) {
761
+ const ch = src[k];
762
+
763
+ if (ch === "'" && !inD) {
764
+ inS = !inS;
765
+ k += 1;
766
+ continue;
767
+ }
768
+ if (ch === '"' && !inS) {
769
+ inD = !inD;
770
+ k += 1;
771
+ continue;
772
+ }
773
+ if (inS || inD) {
774
+ k += 1;
775
+ continue;
776
+ }
777
+
778
+ if (ch === '[') bracketDepth += 1;
779
+ else if (ch === ']') {
780
+ if (bracketDepth > 0) bracketDepth -= 1;
781
+ else break;
782
+ }
783
+
784
+ if (bracketDepth === 0 && isBoundary(ch)) break;
785
+ k += 1;
786
+ }
787
+
788
+ return { raw: src.slice(start, k), end: k };
789
+ };
790
+
791
+ const readVarLensAt = (src, start) => {
792
+ if (src[start] !== '$') return null;
793
+
794
+ let j = start + 1;
795
+ if (!/[A-Za-z_]/.test(src[j] || '')) return null;
796
+ j += 1;
797
+ while (j < src.length && /[\w.-]/.test(src[j])) j += 1;
798
+
799
+ const varName = src.slice(start + 1, j);
800
+
801
+ let k = j;
802
+ while (k < src.length && /\s/.test(src[k])) k += 1;
803
+
804
+ // We only care if a lookup tail follows: ?foo?bar OR /foo/bar
805
+ const next = src[k];
806
+ if (next !== '?' && next !== '.' && next !== '/') {
807
+ return { raw: src.slice(start, j), end: j, varName, tail: '' };
808
+ }
809
+
810
+ // normalize .?foo?bar => ?foo?bar
811
+ if (next === '.' && src[k + 1] === '?') k += 1;
812
+
813
+ // read until boundary
814
+ let p = k;
815
+ let bracketDepth = 0;
816
+ let inS = false;
817
+ let inD = false;
818
+
819
+ while (p < src.length) {
820
+ const ch = src[p];
821
+
822
+ if (ch === "'" && !inD) {
823
+ inS = !inS;
824
+ p += 1;
825
+ continue;
826
+ }
827
+ if (ch === '"' && !inS) {
828
+ inD = !inD;
829
+ p += 1;
830
+ continue;
831
+ }
832
+ if (inS || inD) {
833
+ p += 1;
834
+ continue;
835
+ }
836
+
837
+ if (ch === '[') bracketDepth += 1;
838
+ else if (ch === ']') {
839
+ if (bracketDepth > 0) bracketDepth -= 1;
840
+ else break;
841
+ }
842
+
843
+ if (bracketDepth === 0 && p !== k && isBoundary(ch)) break;
844
+ p += 1;
845
+ }
846
+
847
+ return {
848
+ raw: src.slice(start, p),
849
+ end: p,
850
+ varName,
851
+ tail: src.slice(k, p),
852
+ };
853
+ };
854
+
855
+ // Collect lookups used inside predicates
856
+ for (const predicate of predicates) {
857
+ const src = String(predicate ?? '');
858
+ let inSingle = false;
859
+ let inDouble = false;
860
+
861
+ for (let i = 0; i < src.length; i += 1) {
862
+ const ch = src[i];
863
+
864
+ if (ch === "'" && !inDouble) {
865
+ inSingle = !inSingle;
866
+ continue;
867
+ }
868
+ if (ch === '"' && !inSingle) {
869
+ inDouble = !inDouble;
870
+ continue;
871
+ }
872
+ if (inSingle || inDouble) continue;
873
+
874
+ // instance('x')?foo?bar
875
+ const instLens = readInstanceLensAt(src, i);
876
+ if (instLens && instLens.raw && instLens.raw.includes('?')) {
877
+ lookups.add(instLens.raw.trim());
878
+ i = instLens.end - 1;
879
+ continue;
880
+ }
881
+
882
+ // $default?ui?query or $foo?bar (rewrite to instance() / instance('foo'))
883
+ const varLens = readVarLensAt(src, i);
884
+ if (
885
+ varLens &&
886
+ varLens.tail &&
887
+ (varLens.tail.startsWith('?') || varLens.tail.startsWith('/'))
888
+ ) {
889
+ const v = varLens.varName;
890
+ const { tail } = varLens;
891
+
892
+ // $default must follow your semantics: first instance in doc order => instance()
893
+ const rewritten =
894
+ v === 'default'
895
+ ? `instance()${tail.startsWith('?') ? tail : tail}`
896
+ : `instance('${v}')${tail}`;
897
+
898
+ lookups.add(rewritten.trim());
899
+ i = varLens.end - 1;
900
+ }
901
+ }
902
+ }
903
+
904
+ if (lookups.size === 0) return;
905
+
906
+ // Resolve lookups to actual nodes and observe them
907
+ for (const lookup of lookups) {
908
+ try {
909
+ const resolved = evaluateXPath(lookup, contextNode, this);
910
+
911
+ let node = null;
912
+ if (Array.isArray(resolved)) {
913
+ const first = resolved[0];
914
+ node = Array.isArray(first) ? first[0] : first;
915
+ } else {
916
+ node = resolved;
917
+ }
918
+
919
+ if (!node) continue;
920
+
921
+ const mi = FxModel.lazyCreateModelItem(model, lookup, node, this);
922
+ if (mi && typeof mi.addObserver === 'function') {
923
+ if (!this._jsonPredicateDeps.has(mi)) {
924
+ mi.addObserver(this);
925
+ this._jsonPredicateDeps.add(mi);
926
+ }
927
+ }
928
+ } catch (_e) {
929
+ // ignore
930
+ }
931
+ }
932
+
933
+ this._jsonPredicateDepsObserved = true;
934
+ }
935
+
449
936
  _evalNodeset() {
450
- // const inscope = this.getInScopeContext();
451
937
  const inscope = getInScopeContext(this.getAttributeNode('ref') || this, this.ref);
452
- // console.log('##### inscope ', inscope);
453
- // console.log('##### ref ', this.ref);
454
- // now we got a nodeset and attach MutationObserver to it
455
938
  if (!inscope) return;
939
+
456
940
  if (this.mutationObserver && inscope.nodeName) {
457
941
  this.mutationObserver.observe(inscope, {
458
942
  childList: true,
@@ -460,64 +944,62 @@ export class FxRepeat extends withDraggability(UIElement, false) {
460
944
  });
461
945
  }
462
946
 
463
- /*
464
- this.touchedPaths = new Set();
465
- const instance = XPathUtil.resolveInstance(this, this.ref);
466
- const depTrackDomfacade = new DependencyNotifyingDomFacade((node) => {
467
- this.touchedPaths.add(XPathUtil.getPath(node, instance));
468
- });
469
- const rawNodeset = evaluateXPath(this.ref, inscope, this, {}, {}, depTrackDomfacade );
470
- */
471
947
  const rawNodeset = evaluateXPath(this.ref, inscope, this);
472
948
 
473
- // console.log('Touched!', this.ref, [...this.touchedPaths].join(', '));
949
+ this._observeJsonPredicateDependencies(inscope);
950
+
474
951
  if (rawNodeset.length === 1 && Array.isArray(rawNodeset[0])) {
475
- // This XPath likely returned an XPath array. Just collapse to that array
476
952
  this.nodeset = rawNodeset[0];
477
953
  return;
478
954
  }
479
955
  this.nodeset = rawNodeset;
480
956
  }
481
957
 
482
- async refresh(force) {
483
- console.log('🔄 fx-repeat.refresh on', this.id);
958
+ /**
959
+ * Observer callback for ModelItem notifications.
960
+ * When a predicate dependency changes (eg. $default?ui?query),
961
+ * schedule this repeat for refresh so its nodeset/predicate is re-evaluated.
962
+ */
963
+ update(_modelItem) {
964
+ const fore = this.getOwnerForm && this.getOwnerForm();
965
+ if (fore && typeof fore.addToBatchedNotifications === 'function') {
966
+ fore.addToBatchedNotifications(this);
967
+ return;
968
+ }
969
+ // Fallback (should rarely happen)
970
+ this.refresh(true);
971
+ }
484
972
 
973
+ async refresh(force) {
485
974
  if (!this.inited) this.init();
486
- // console.time('repeat-refresh', this);
487
- this._evalNodeset();
488
975
 
489
- // console.log('repeat refresh nodeset ', this.nodeset);
490
- // console.log('repeatCount', this.repeatCount);
976
+ this._evalNodeset();
491
977
 
492
- const repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
493
- const repeatItemCount = repeatItems.length;
978
+ let repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
979
+ let repeatItemCount = repeatItems.length;
494
980
 
495
981
  let nodeCount = 1;
496
982
  if (Array.isArray(this.nodeset)) {
497
983
  nodeCount = this.nodeset.length;
498
984
  }
499
985
 
500
- // const contextSize = this.nodeset.length;
501
986
  const contextSize = nodeCount;
502
- // todo: review - cant the context really never be smaller than the repeat count?
503
- // todo: this code can be deprecated probably but check first
987
+
504
988
  if (contextSize < repeatItemCount) {
505
989
  for (let position = repeatItemCount; position > contextSize; position -= 1) {
506
- // remove repeatitem
507
990
  const itemToRemove = repeatItems[position - 1];
508
991
  itemToRemove.parentNode.removeChild(itemToRemove);
509
992
  this.getOwnerForm().unRegisterLazyElement(itemToRemove);
510
- // this._fadeOut(itemToRemove);
511
- // Fore.fadeOutElement(itemToRemove)
512
993
  }
513
994
  }
514
995
 
996
+ // DOM changed: re-query repeatitems
997
+ repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
998
+ repeatItemCount = repeatItems.length;
999
+
515
1000
  if (contextSize > repeatItemCount) {
516
1001
  for (let position = repeatItemCount + 1; position <= contextSize; position += 1) {
517
- // add new repeatitem
518
-
519
1002
  const newItem = this._createNewRepeatItem();
520
-
521
1003
  this.appendChild(newItem);
522
1004
 
523
1005
  this._initVariables(newItem);
@@ -529,14 +1011,16 @@ export class FxRepeat extends withDraggability(UIElement, false) {
529
1011
  this.getOwnerForm().initData(newItem);
530
1012
  }
531
1013
 
532
- // Tell the owner form we might have new template expressions here
533
1014
  this.getOwnerForm().scanForNewTemplateExpressionsNextRefresh();
534
1015
 
535
1016
  newItem.refresh(true);
536
1017
  }
537
1018
  }
538
1019
 
539
- // ### update nodeset of repeatitems
1020
+ // DOM changed: re-query repeatitems
1021
+ repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
1022
+ repeatItemCount = repeatItems.length;
1023
+
540
1024
  for (let position = 0; position < repeatItemCount; position += 1) {
541
1025
  const item = repeatItems[position];
542
1026
  this.getOwnerForm().registerLazyElement(item);
@@ -549,67 +1033,27 @@ export class FxRepeat extends withDraggability(UIElement, false) {
549
1033
  }
550
1034
  }
551
1035
 
552
- // Fore.refreshChildren(clone, true);
553
1036
  const fore = this.getOwnerForm();
554
- // if (!fore.lazyRefresh || force) {
555
1037
  if (!fore.lazyRefresh || force) {
556
- // Turn the possibly conditional force refresh into a forced one: we changed our children
557
- Fore.refreshChildren(this, force);
1038
+ await Fore.refreshChildren(this, force);
558
1039
  }
559
- // this.style.display = 'block';
560
- // this.style.display = this.display;
561
- this.setIndex(this.index);
562
- // console.timeEnd('repeat-refresh');
563
-
564
- // this.replaceWith(clone);
565
-
566
- // this.repeatCount = contextSize;
567
- // console.log('repeatCount', this.repeatCount);
568
- }
569
-
570
- // eslint-disable-next-line class-methods-use-this
571
- _fadeOut(el) {
572
- el.style.opacity = 1;
573
-
574
- (function fade() {
575
- // eslint-disable-next-line no-cond-assign
576
- if ((el.style.opacity -= 0.1) < 0) {
577
- el.style.display = 'none';
578
- } else {
579
- requestAnimationFrame(fade);
580
- }
581
- })();
582
- }
583
1040
 
584
- // eslint-disable-next-line class-methods-use-this
585
- _fadeIn(el) {
586
- if (!el) return;
587
-
588
- el.style.opacity = 0;
589
- el.style.display = this.display;
590
-
591
- (function fade() {
592
- // setTimeout(() => {
593
- let val = parseFloat(el.style.opacity);
594
- // eslint-disable-next-line no-cond-assign
595
- if (!((val += 0.1) > 1)) {
596
- el.style.opacity = val;
597
- requestAnimationFrame(fade);
598
- }
599
- // }, 40);
600
- })();
1041
+ this.setIndex(this.index);
601
1042
  }
602
1043
 
603
1044
  _initTemplate() {
604
- // console.log('### init template for repeat ', this.id, this.template);
605
- // todo: this.dropTarget not needed?
606
- this.dropTarget = this.template.getAttribute('drop-target');
607
- this.isDraggable = this.template.hasAttribute('draggable')
608
- ? this.template.getAttribute('draggable')
609
- : null;
1045
+ // Template can be missing during early init (slot timing / nested repeats).
1046
+ // Never dereference it before we have it.
1047
+ if (!this.template) {
1048
+ // Prefer a direct child template, then any descendant template.
1049
+ this.template =
1050
+ Array.from(this.children).find(c => c && c.localName === 'template') ||
1051
+ this.querySelector('template') ||
1052
+ (this.shadowRoot && this.shadowRoot.querySelector('template')) ||
1053
+ null;
1054
+ }
610
1055
 
611
1056
  if (this.template === null) {
612
- // todo: catch this on form element
613
1057
  this.dispatchEvent(
614
1058
  new CustomEvent('no-template-error', {
615
1059
  composed: true,
@@ -617,35 +1061,42 @@ export class FxRepeat extends withDraggability(UIElement, false) {
617
1061
  detail: { message: `no template found for repeat:${this.id}` },
618
1062
  }),
619
1063
  );
1064
+ return;
620
1065
  }
621
1066
 
622
- this.shadowRoot.appendChild(this.template);
1067
+ this.dropTarget = this.template.getAttribute('drop-target');
1068
+ this.isDraggable = this.template.hasAttribute('draggable')
1069
+ ? this.template.getAttribute('draggable')
1070
+ : null;
1071
+
1072
+ // Move template to shadow for safe reuse.
1073
+ // If it's already in the shadowRoot, don't append again.
1074
+ if (this.template.parentNode !== this.shadowRoot) {
1075
+ this.shadowRoot.appendChild(this.template);
1076
+ }
623
1077
  }
624
1078
 
625
1079
  _initRepeatItems() {
626
1080
  this.nodeset.forEach((item, index) => {
627
1081
  const repeatItem = this._createNewRepeatItem();
628
1082
  repeatItem.nodeset = this.nodeset[index];
629
- repeatItem.index = index + 1; // 1-based index
1083
+ repeatItem.index = index + 1;
630
1084
 
631
1085
  this.appendChild(repeatItem);
632
1086
 
633
1087
  if (this.getOwnerForm().createNodes) {
634
1088
  this.getOwnerForm().initData(repeatItem);
635
1089
  if (repeatItem.nodeset.nodeType) {
636
- // Do not try to d things with repeats that do not reason over nodes
637
1090
  const repeatItemClone = repeatItem.nodeset.cloneNode(true);
638
1091
  this.clearTextValues(repeatItemClone);
639
- // this.createdNodeset = repeatItem.nodeset.cloneNode(true);
640
1092
  this.createdNodeset = repeatItemClone;
641
1093
  }
642
- // console.log('createdNodeset', this.createdNodeset)
643
1094
  }
644
1095
 
645
1096
  if (repeatItem.index === 1) {
646
1097
  this.applyIndex(repeatItem);
647
1098
  }
648
- // console.log('*********repeat item created', repeatItem.nodeset);
1099
+
649
1100
  Fore.dispatch(this, 'item-created', { nodeset: repeatItem.nodeset, pos: index + 1 });
650
1101
  this._initVariables(repeatItem);
651
1102
  });
@@ -654,19 +1105,16 @@ export class FxRepeat extends withDraggability(UIElement, false) {
654
1105
  clearTextValues(node) {
655
1106
  if (!node) return;
656
1107
 
657
- // Clear text node content
658
1108
  if (node.nodeType === Node.TEXT_NODE) {
659
1109
  node.nodeValue = '';
660
1110
  }
661
1111
 
662
- // Clear all attribute values
663
1112
  if (node.nodeType === Node.ELEMENT_NODE) {
664
1113
  for (const attr of Array.from(node.attributes)) {
665
- attr.value = ''; // Clear attribute value
1114
+ attr.value = '';
666
1115
  }
667
1116
  }
668
1117
 
669
- // Recursively clear child nodes
670
1118
  for (const child of node.childNodes) {
671
1119
  this.clearTextValues(child);
672
1120
  }
@@ -685,17 +1133,7 @@ export class FxRepeat extends withDraggability(UIElement, false) {
685
1133
  })(newRepeatItem);
686
1134
  }
687
1135
 
688
- /*
689
- _clone() {
690
- // const content = this.template.content.cloneNode(true);
691
- this.template = this.shadowRoot.querySelector('template');
692
- const content = this.template.content.cloneNode(true);
693
- return document.importNode(content, true);
694
- }
695
- */
696
-
697
1136
  _clone() {
698
- // Prefer the cached template set in _initTemplate; fall back to either DOM.
699
1137
  const tpl =
700
1138
  this.template ||
701
1139
  (this.shadowRoot && this.shadowRoot.querySelector('template')) ||
@@ -717,8 +1155,6 @@ export class FxRepeat extends withDraggability(UIElement, false) {
717
1155
  }
718
1156
 
719
1157
  setInScopeVariables(inScopeVariables) {
720
- // Repeats are interesting: the variables should be scoped per repeat item, they should not be
721
- // able to see the variables in adjacent repeat items!
722
1158
  this.inScopeVariables = new Map(inScopeVariables);
723
1159
  }
724
1160
  }