@jinntec/fore 3.0.1 → 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.
package/src/fx-fore.js CHANGED
@@ -2,7 +2,11 @@ import { Fore } from './fore.js';
2
2
  import './fx-instance.js';
3
3
  import { FxModel } from './fx-model.js';
4
4
  import '@jinntec/jinn-toast';
5
- import { evaluateXPathToNodes, evaluateXPathToString, createNamespaceResolver } from './xpath-evaluation.js';
5
+ import {
6
+ evaluateXPathToNodes,
7
+ evaluateXPathToString,
8
+ createNamespaceResolver,
9
+ } from './xpath-evaluation.js';
6
10
  import getInScopeContext from './getInScopeContext.js';
7
11
  import { XPathUtil } from './xpath-util.js';
8
12
  import { FxRepeatAttributes } from './ui/fx-repeat-attributes.js';
@@ -22,9 +26,7 @@ const dirtyStates = {
22
26
  };
23
27
  async function waitForFunctionLibs(rootEl) {
24
28
  const libs = Array.from(rootEl.querySelectorAll('fx-functionlib'));
25
- await Promise.all(
26
- libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve()))
27
- );
29
+ await Promise.all(libs.map(l => (l.readyPromise ? l.readyPromise : Promise.resolve())));
28
30
  }
29
31
 
30
32
  /**
@@ -222,8 +224,6 @@ export class FxFore extends HTMLElement {
222
224
  right:0;
223
225
  left:0;
224
226
  height:40px;
225
- border-radius: 5px;
226
-
227
227
  }
228
228
  .popup .close {
229
229
  position: absolute;
@@ -248,6 +248,53 @@ export class FxFore extends HTMLElement {
248
248
  .warning{
249
249
  background:orange;
250
250
  }
251
+ #authoringErrors {
252
+ z-index: 20;
253
+ }
254
+ #authoringErrors .popup {
255
+ width: 70%;
256
+ max-height: 80vh;
257
+ overflow:hidden;
258
+ }
259
+ #authoringErrors h2 {
260
+ background: #c62828;
261
+ color: white;
262
+ padding-left: 12px;
263
+ line-height: 40px;
264
+ font-size: 1rem;
265
+ }
266
+ #authoringErrors table {
267
+ width: 100%;
268
+ border-collapse: collapse;
269
+ font-size: 0.85rem;
270
+ }
271
+ #authoringErrors th {
272
+ text-align: left;
273
+ border-bottom: 2px solid #c62828;
274
+ padding: 4px 8px;
275
+ }
276
+ #authoringErrors td {
277
+ padding: 4px 8px;
278
+ border-bottom: 1px solid #ddd;
279
+ vertical-align: top;
280
+ }
281
+ #authoringErrors td:first-child {
282
+ color: #555;
283
+ font-family: monospace;
284
+ white-space: nowrap;
285
+ }
286
+ #authoringErrors .ae-actions {
287
+ text-align: center;
288
+ margin-top: 12px;
289
+ }
290
+ #authoringErrors .ae-actions button {
291
+ padding: 6px 20px;
292
+ background: #c62828;
293
+ color: white;
294
+ border: none;
295
+ border-radius: 3px;
296
+ cursor: pointer;
297
+ }
251
298
  `;
252
299
 
253
300
  const html = `
@@ -265,6 +312,14 @@ export class FxFore extends HTMLElement {
265
312
  <div id="messageContent"></div>
266
313
  </div>
267
314
  </div>
315
+ <div id="authoringErrors" class="overlay">
316
+ <div class="popup">
317
+ <h2>Authoring Errors</h2>
318
+ <a class="close" href="#" onclick="event.preventDefault();event.target.closest('.overlay').classList.remove('show')">&times;</a>
319
+ <div id="authoringErrorsContent" style="margin-top:48px;"></div>
320
+ <div class="ae-actions"><button onclick="this.closest('.overlay').classList.remove('show')">Dismiss</button></div>
321
+ </div>
322
+ </div>
268
323
  <slot name="event"></slot>
269
324
  `;
270
325
 
@@ -281,8 +336,8 @@ export class FxFore extends HTMLElement {
281
336
  this._scanForNewTemplateExpressionsNextRefresh = false;
282
337
  this.repeatsFromAttributesCreated = false;
283
338
  this.validateOn = this.hasAttribute('validate-on')
284
- ? this.getAttribute('validate-on')
285
- : 'update';
339
+ ? this.getAttribute('validate-on')
340
+ : 'update';
286
341
  // this.mergePartial = this.hasAttribute('merge-partial')? true:false;
287
342
  this.mergePartial = false;
288
343
  this.createNodes = this.hasAttribute('create-nodes') ? true : false;
@@ -305,9 +360,9 @@ export class FxFore extends HTMLElement {
305
360
  _parseTargetList(raw) {
306
361
  if (!raw) return [];
307
362
  return raw
308
- .split(/[\s,]+/)
309
- .map(s => s.trim())
310
- .filter(Boolean);
363
+ .split(/[\s,]+/)
364
+ .map(s => s.trim())
365
+ .filter(Boolean);
311
366
  }
312
367
 
313
368
  _findBySelector(sel) {
@@ -323,10 +378,10 @@ export class FxFore extends HTMLElement {
323
378
 
324
379
  _isReadyTarget(el) {
325
380
  return !!(
326
- el &&
327
- (el.ready === true ||
328
- (el.classList && el.classList.contains('fx-ready')) ||
329
- (typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
381
+ el &&
382
+ (el.ready === true ||
383
+ (el.classList && el.classList.contains('fx-ready')) ||
384
+ (typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
330
385
  );
331
386
  }
332
387
 
@@ -343,7 +398,7 @@ export class FxFore extends HTMLElement {
343
398
  if (waitForRaw) {
344
399
  if (!this._warnedWaitForDeprecation) {
345
400
  console.warn(
346
- '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
401
+ '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
347
402
  );
348
403
  this._warnedWaitForDeprecation = true;
349
404
  }
@@ -442,7 +497,7 @@ export class FxFore extends HTMLElement {
442
497
  // Special: closest fx-fore
443
498
  if (targetSpec === 'closest') {
444
499
  const recheckFn =
445
- event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
500
+ event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
446
501
 
447
502
  const matchesFn = ev => {
448
503
  const t = ev.target;
@@ -456,7 +511,7 @@ export class FxFore extends HTMLElement {
456
511
  const selector = targetSpec;
457
512
 
458
513
  const recheckFn =
459
- event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
514
+ event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
460
515
 
461
516
  if (typeof recheckFn === 'function' && recheckFn()) {
462
517
  return Promise.resolve();
@@ -489,7 +544,7 @@ export class FxFore extends HTMLElement {
489
544
  }
490
545
 
491
546
  this._initGatesPromise = Promise.all(gates.map(g => this._waitForInitGate(g))).then(
492
- () => undefined,
547
+ () => undefined,
493
548
  );
494
549
  return this._initGatesPromise;
495
550
  }
@@ -524,7 +579,7 @@ export class FxFore extends HTMLElement {
524
579
  }
525
580
  // Fallback for odd engines/polyfills
526
581
  return (slotEl.assignedNodes({ flatten: true }) || []).filter(
527
- n => n.nodeType === Node.ELEMENT_NODE,
582
+ n => n.nodeType === Node.ELEMENT_NODE,
528
583
  );
529
584
  };
530
585
 
@@ -542,8 +597,8 @@ export class FxFore extends HTMLElement {
542
597
  }
543
598
  if (!modelElement.inited) {
544
599
  console.info(
545
- `%cFore running ... ${this.id ? '#' + this.id : ''}`,
546
- 'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
600
+ `%cFore running ... ${this.id ? '#' + this.id : ''}`,
601
+ 'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
547
602
  );
548
603
 
549
604
  const variables = new Map();
@@ -562,7 +617,7 @@ export class FxFore extends HTMLElement {
562
617
  await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
563
618
 
564
619
  await modelElement.modelConstruct();
565
- console.log("varbindings ",this._instanceVarBindings);
620
+ console.log('varbindings ', this._instanceVarBindings);
566
621
  this._handleModelConstructDone();
567
622
  }
568
623
 
@@ -570,7 +625,6 @@ export class FxFore extends HTMLElement {
570
625
  this.inited = true;
571
626
  };
572
627
 
573
-
574
628
  attributeChangedCallback(name, oldValue, newValue) {
575
629
  if (oldValue === newValue) return;
576
630
 
@@ -611,7 +665,7 @@ export class FxFore extends HTMLElement {
611
665
 
612
666
  connectedCallback() {
613
667
  const modelElement = Array.from(this.children).find(
614
- modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
668
+ modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
615
669
  );
616
670
 
617
671
  this.model = modelElement;
@@ -638,8 +692,8 @@ export class FxFore extends HTMLElement {
638
692
  },true);
639
693
  */
640
694
  this.ignoreExpressions = this.hasAttribute('ignore-expressions')
641
- ? this.getAttribute('ignore-expressions')
642
- : null;
695
+ ? this.getAttribute('ignore-expressions')
696
+ : null;
643
697
 
644
698
  this.lazyRefresh = this.hasAttribute('refresh-on-view');
645
699
  if (this.lazyRefresh) {
@@ -700,9 +754,9 @@ export class FxFore extends HTMLElement {
700
754
 
701
755
  // Collect existing fx-var names at fx-fore scope (author-defined and previously generated)
702
756
  const existingVars = new Set(
703
- Array.from(this.querySelectorAll(':scope > fx-var'))
704
- .map(v => (v.getAttribute('name') || '').trim())
705
- .filter(Boolean),
757
+ Array.from(this.querySelectorAll(':scope > fx-var'))
758
+ .map(v => (v.getAttribute('name') || '').trim())
759
+ .filter(Boolean),
706
760
  );
707
761
 
708
762
  let defaultAssigned = false;
@@ -790,13 +844,13 @@ export class FxFore extends HTMLElement {
790
844
  markAsClean() {
791
845
  console.log('marking as clean', this);
792
846
  this.addEventListener(
793
- 'value-changed',
794
- () => {
795
- console.log('MARK as modified', this)
796
- this.dirtyState = dirtyStates.DIRTY;
797
- this.classList.toggle('fx-modified')
798
- },
799
- { once: true },
847
+ 'value-changed',
848
+ () => {
849
+ console.log('MARK as modified', this);
850
+ this.dirtyState = dirtyStates.DIRTY;
851
+ this.classList.toggle('fx-modified');
852
+ },
853
+ { once: true },
800
854
  );
801
855
  this.dirtyState = dirtyStates.CLEAN;
802
856
  this.classList.remove('fx-modified');
@@ -894,8 +948,11 @@ export class FxFore extends HTMLElement {
894
948
 
895
949
  try {
896
950
  if (force === true || this.initialRun) {
951
+ performance.mark('force-refresh-start');
897
952
  console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
898
953
  await Fore.refreshChildren(this, force);
954
+ performance.mark('force-refresh-end');
955
+ performance.measure('force-refresh', 'force-refresh-start', 'force-refresh-end');
899
956
  } else {
900
957
  await this._processBatchedNotifications();
901
958
  }
@@ -912,9 +969,9 @@ export class FxFore extends HTMLElement {
912
969
  this.style.visibility = 'visible';
913
970
 
914
971
  console.info(
915
- `%c ✅ refresh-done on #${this.id}`,
916
- 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
917
- this.getModel().modelItems,
972
+ `%c ✅ refresh-done on #${this.id}`,
973
+ 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
974
+ this.getModel().modelItems,
918
975
  );
919
976
 
920
977
  Fore.dispatch(this, 'refresh-done', {});
@@ -954,7 +1011,7 @@ export class FxFore extends HTMLElement {
954
1011
  */
955
1012
  _processBatchedNotifications() {
956
1013
  if (this.batchedNotifications.size > 0) {
957
- console.log(`🔄 🎯 ### processing ${ this.batchedNotifications.size} batched notifications`);
1014
+ console.log(`🔄 🎯 ### processing ${this.batchedNotifications.size} batched notifications`);
958
1015
  console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
959
1016
 
960
1017
  // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
@@ -1016,7 +1073,7 @@ export class FxFore extends HTMLElement {
1016
1073
  */
1017
1074
  _updateTemplateExpressions() {
1018
1075
  const search =
1019
- "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
1076
+ "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
1020
1077
 
1021
1078
  const tmplExpressions = evaluateXPathToNodes(search, this, this);
1022
1079
  // console.log('template expressions found ', tmplExpressions);
@@ -1027,7 +1084,7 @@ export class FxFore extends HTMLElement {
1027
1084
 
1028
1085
  // console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
1029
1086
 
1030
- if(!tmplExpressions) return;
1087
+ if (!tmplExpressions) return;
1031
1088
  /*
1032
1089
  storing expressions and their nodes for re-evaluation
1033
1090
  */
@@ -1104,7 +1161,7 @@ export class FxFore extends HTMLElement {
1104
1161
  if (el && el.localName === 'fx-insert' && node.name === 'origin') {
1105
1162
  const v = String(node.value ?? '').trim();
1106
1163
  const isJsonLiteral =
1107
- (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
1164
+ (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
1108
1165
  if (isJsonLiteral) return;
1109
1166
  }
1110
1167
  }
@@ -1115,16 +1172,16 @@ export class FxFore extends HTMLElement {
1115
1172
  // - fx-var scoping (in-scope variables)
1116
1173
  // - context() in repeats (repeat item detection)
1117
1174
  const definitionElement =
1118
- node.nodeType === Node.ATTRIBUTE_NODE
1119
- ? node.ownerElement
1120
- : node.nodeType === Node.TEXT_NODE
1121
- ? (node.parentElement || node.parentNode)
1122
- : node;
1175
+ node.nodeType === Node.ATTRIBUTE_NODE
1176
+ ? node.ownerElement
1177
+ : node.nodeType === Node.TEXT_NODE
1178
+ ? node.parentElement || node.parentNode
1179
+ : node;
1123
1180
 
1124
1181
  const formElement =
1125
- definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
1126
- ? definitionElement
1127
- : this;
1182
+ definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
1183
+ ? definitionElement
1184
+ : this;
1128
1185
 
1129
1186
  const replaced = String(expr ?? '').replace(/{[^}]*}/g, match => {
1130
1187
  if (match === '{}') return match;
@@ -1159,7 +1216,7 @@ export class FxFore extends HTMLElement {
1159
1216
  node.textContent = replaced;
1160
1217
  }
1161
1218
  }
1162
- } // eslint-disable-next-line class-methods-use-this
1219
+ } // eslint-disable-next-line class-methods-use-this
1163
1220
  _getTemplateExpression(node) {
1164
1221
  if (this.ignoredNodes) {
1165
1222
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1208,17 +1265,17 @@ export class FxFore extends HTMLElement {
1208
1265
 
1209
1266
  // ##### lazy creation should NOT take place if there's a parent Fore using shared instances
1210
1267
  const parentFore =
1211
- this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
1212
- ? this.parentNode.closest('fx-fore')
1213
- : null;
1268
+ this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
1269
+ ? this.parentNode.closest('fx-fore')
1270
+ : null;
1214
1271
  if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1215
1272
  console.log('fragment', this.parentNode);
1216
1273
  }
1217
1274
 
1218
1275
  if (parentFore) {
1219
1276
  const shared = parentFore
1220
- .getModel()
1221
- .instances.filter(shared => shared.hasAttribute('shared'));
1277
+ .getModel()
1278
+ .instances.filter(shared => shared.hasAttribute('shared'));
1222
1279
  if (shared.length !== 0) return;
1223
1280
  }
1224
1281
 
@@ -1239,8 +1296,8 @@ export class FxFore extends HTMLElement {
1239
1296
  }
1240
1297
  } catch (e) {
1241
1298
  console.warn(
1242
- 'lazyCreateInstance created an error attempting to create a document',
1243
- e.message,
1299
+ 'lazyCreateInstance created an error attempting to create a document',
1300
+ e.message,
1244
1301
  );
1245
1302
  }
1246
1303
  }
@@ -1297,8 +1354,8 @@ export class FxFore extends HTMLElement {
1297
1354
  */
1298
1355
  async _initUI() {
1299
1356
  console.info(
1300
- `%cinitUI #${this.id}`,
1301
- 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1357
+ `%cinitUI #${this.id}`,
1358
+ 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1302
1359
  );
1303
1360
 
1304
1361
  const parentFore = this.closest('fx-fore');
@@ -1320,7 +1377,12 @@ export class FxFore extends HTMLElement {
1320
1377
 
1321
1378
  // First refresh should be forced
1322
1379
  if (this.createNodes) {
1380
+ performance.mark('initData-start');
1323
1381
  this.initData();
1382
+ performance.mark('initData-end');
1383
+
1384
+ performance.measure('initData', 'initData-start', 'initData-end');
1385
+
1324
1386
  const binds = this.getModel().querySelector('fx-bind');
1325
1387
  if (binds) {
1326
1388
  this.getModel().updateModel();
@@ -1338,13 +1400,12 @@ export class FxFore extends HTMLElement {
1338
1400
  this.initialRun = false;
1339
1401
  // console.log('### >>>>> dispatching ready >>>>>', this);
1340
1402
  console.info(
1341
- `%c ✅ ${this.id ? '#' + this.id : 'Fore'} is ready`,
1342
- 'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1403
+ `%c ✅ ${this.id ? '#' + this.id : 'Fore'} is ready`,
1404
+ 'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1343
1405
  );
1344
1406
 
1345
1407
  // console.log(`### <<<<< ${this.id} ready >>>>>`);
1346
1408
 
1347
- // console.log('### modelItems: ', this.getModel().modelItems);
1348
1409
  Fore.dispatch(this, 'ready', {});
1349
1410
  // console.log('dataChanged', FxModel.dataChanged);
1350
1411
  this.markAsClean();
@@ -1358,6 +1419,50 @@ export class FxFore extends HTMLElement {
1358
1419
  e.stopPropagation();
1359
1420
  e.dataTransfer.dropEffect = 'move';
1360
1421
  });
1422
+
1423
+ // Run authoring checks after ready — they're diagnostic only and must not delay
1424
+ // the ready event or drag-listener registration (both of which tests depend on).
1425
+ try {
1426
+ await this._runAuthoringChecks();
1427
+ } catch (e) {
1428
+ console.warn('[fore] authoring check failed:', e.message);
1429
+ }
1430
+ }
1431
+
1432
+ async _runAuthoringChecks() {
1433
+ if (this.hasAttribute('no-check')) return;
1434
+ if (new URLSearchParams(window.location.search).has('no-check')) return;
1435
+
1436
+ const { checkAuthoring } = await import('./authoring-check.js');
1437
+ const errors = checkAuthoring(this);
1438
+ if (errors.length) {
1439
+ this._showAuthoringErrors(errors);
1440
+ }
1441
+ }
1442
+
1443
+ _showAuthoringErrors(errors) {
1444
+ const overlay = this.shadowRoot.getElementById('authoringErrors');
1445
+ const content = this.shadowRoot.getElementById('authoringErrorsContent');
1446
+ if (!overlay || !content) return;
1447
+
1448
+ const rows = errors
1449
+ .map(({ element, message }) => {
1450
+ const path = element
1451
+ ? element.tagName.toLowerCase() + (element.id ? `#${element.id}` : '')
1452
+ : '?';
1453
+ const safeMsg = message.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1454
+ const safePath = path.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1455
+ return `<tr><td>${safePath}</td><td>${safeMsg}</td></tr>`;
1456
+ })
1457
+ .join('');
1458
+
1459
+ content.innerHTML = `
1460
+ <table>
1461
+ <thead><tr><th>Element</th><th>Problem</th></tr></thead>
1462
+ <tbody>${rows}</tbody>
1463
+ </table>`;
1464
+
1465
+ overlay.classList.add('show');
1361
1466
  }
1362
1467
 
1363
1468
  /**
@@ -1438,7 +1543,7 @@ export class FxFore extends HTMLElement {
1438
1543
  const lastMatchingSibling = nodeset.reverse().find(node => parentElement.contains(node));
1439
1544
  if (lastMatchingSibling) {
1440
1545
  return lastMatchingSibling;
1441
- }
1546
+ }
1442
1547
  // Otherwise, just default to appending... If this runs multiple times for multiple nodes
1443
1548
  // it's unexpected to always prepend and get the order of children reversed from the UI.
1444
1549
 
@@ -1465,25 +1570,187 @@ export class FxFore extends HTMLElement {
1465
1570
  }
1466
1571
  /**
1467
1572
  * @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
1468
- *
1469
1573
  */
1470
1574
  initData(root = this) {
1471
- // const created = new Promise(resolve => {
1472
- // console.log('INIT');
1473
- // const boundControls = Array.from(root.querySelectorAll('[ref]:not(fx-model *),fx-repeatitem'));
1575
+ /**
1576
+ * @param {*} value
1577
+ * @returns {boolean}
1578
+ */
1579
+ const isObjectLike = value =>
1580
+ value !== null && (typeof value === 'object' || typeof value === 'function');
1581
+
1582
+ /**
1583
+ * @param {*} nodeset
1584
+ * @returns {boolean}
1585
+ */
1586
+ const hasResolvedNodeset = nodeset => {
1587
+ if (nodeset == null) return false;
1588
+
1589
+ // Atomic values like strings/numbers/booleans are valid resolved XPath results
1590
+ if (!isObjectLike(nodeset)) return true;
1591
+
1592
+ if (Array.isArray(nodeset)) return nodeset.length > 0;
1593
+
1594
+ if (typeof nodeset.length === 'number' && !('nodeType' in nodeset)) {
1595
+ return nodeset.length > 0;
1596
+ }
1597
+
1598
+ if (typeof nodeset[Symbol.iterator] === 'function' && !('nodeType' in nodeset)) {
1599
+ for (const item of nodeset) {
1600
+ return item !== undefined;
1601
+ }
1602
+ return false;
1603
+ }
1604
+
1605
+ return !!nodeset.nodeType;
1606
+ };
1607
+
1608
+ /**
1609
+ * Only try create-nodes for path-like refs, not general expressions like sequences.
1610
+ * @param {string} ref
1611
+ * @returns {boolean}
1612
+ */
1613
+ const isCreateNodesCandidate = ref => {
1614
+ const expr = String(ref || '').trim();
1615
+ if (!expr || expr === '.') return false;
1616
+ if (expr.startsWith('"') || expr.startsWith("'")) return false;
1617
+
1618
+ // Ignore only simple literal sequences like ('a', 'b', 'c') or (1, 2, 3).
1619
+ // Keep fx-repeat refs that are real path expressions or more complex XPath.
1620
+ const simpleSequencePattern =
1621
+ /^\(\s*(?:(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.)(?:\s*,\s*(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.))*)?\s*\)$/;
1622
+ if (simpleSequencePattern.test(expr)) return false;
1623
+
1624
+ return true;
1625
+ };
1626
+
1627
+ /**
1628
+ * Detect whether a ref ends in an attribute step.
1629
+ * @param {string} ref
1630
+ * @returns {boolean}
1631
+ */
1632
+ const isAttributeRef = ref => /(^|\/)\s*@/.test(String(ref || '').trim());
1633
+
1634
+ /**
1635
+ * Normalize a possibly sequence-like nodeset/context to a single DOM node.
1636
+ * @param {*} candidate
1637
+ * @returns {*}
1638
+ */
1639
+ const firstNode = candidate => {
1640
+ if (!candidate) return null;
1641
+ if (!isObjectLike(candidate)) return null;
1642
+ if (candidate.nodeType) return candidate;
1643
+
1644
+ if (Array.isArray(candidate)) {
1645
+ return candidate.find(item => item && isObjectLike(item) && item.nodeType) || null;
1646
+ }
1647
+
1648
+ if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
1649
+ for (let i = 0; i < candidate.length; i += 1) {
1650
+ const item = candidate.item(i);
1651
+ if (item && isObjectLike(item) && item.nodeType) return item;
1652
+ }
1653
+ }
1654
+
1655
+ return null;
1656
+ };
1657
+
1658
+ /**
1659
+ * Detect XPath results that are sequences of atomic values rather than DOM nodes.
1660
+ * These are valid resolved results for repeats, but they must never trigger create-nodes.
1661
+ * @param {*} candidate
1662
+ * @returns {boolean}
1663
+ */
1664
+ const isAtomicSequence = candidate => {
1665
+ if (!candidate) return false;
1666
+
1667
+ // A single primitive value is also atomic from our perspective
1668
+ if (!isObjectLike(candidate)) return true;
1669
+
1670
+ if (candidate.nodeType) return false;
1671
+
1672
+ if (Array.isArray(candidate)) {
1673
+ return (
1674
+ candidate.length > 0 &&
1675
+ !candidate.some(item => item && isObjectLike(item) && item.nodeType)
1676
+ );
1677
+ }
1678
+
1679
+ if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
1680
+ return false;
1681
+ }
1682
+
1683
+ if (typeof candidate[Symbol.iterator] === 'function') {
1684
+ for (const item of candidate) {
1685
+ return !(item && isObjectLike(item) && item.nodeType);
1686
+ }
1687
+ }
1688
+
1689
+ return false;
1690
+ };
1691
+
1692
+ /**
1693
+ * Check whether a bound element is resolved after evalInContext.
1694
+ * Attribute refs often expose an empty string as nodeset, so use the model item node in that case.
1695
+ * @param {import('./ForeElementMixin.js').default} bound
1696
+ * @returns {boolean}
1697
+ */
1698
+ const isResolvedBound = bound => {
1699
+ if (hasResolvedNodeset(bound.nodeset)) return true;
1700
+ if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) return true;
1701
+ if (isAttributeRef(bound.ref)) {
1702
+ const modelItem = typeof bound.getModelItem === 'function' ? bound.getModelItem() : null;
1703
+ return !!modelItem?.node;
1704
+ }
1705
+ return false;
1706
+ };
1707
+
1708
+ /**
1709
+ * Determine the best context node for lazy node creation.
1710
+ * Prefer the bound element's in-scope context, but fall back to a structural parent.
1711
+ * @param {import('./ForeElementMixin.js').default} bound
1712
+ * @param {*} fallback
1713
+ * @returns {*}
1714
+ */
1715
+ const getCreationContext = (bound, fallback) => {
1716
+ const direct =
1717
+ typeof bound.getInScopeContext === 'function' ? firstNode(bound.getInScopeContext()) : null;
1718
+ if (direct) return direct;
1719
+
1720
+ const dotCtx = firstNode(getInScopeContext(bound, '.'));
1721
+ if (dotCtx) return dotCtx;
1722
+
1723
+ const refCtx = firstNode(getInScopeContext(bound, bound.ref));
1724
+ if (refCtx) return refCtx;
1725
+
1726
+ return firstNode(fallback);
1727
+ };
1474
1728
 
1475
1729
  /**
1476
1730
  * @type {import('./ForeElementMixin.js').default[]}
1477
1731
  */
1478
1732
  const boundControls = Array.from(
1479
- root.querySelectorAll(
1480
- 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]',
1481
- ),
1482
- );
1483
- if (root.matches && root.matches('fx-repeatitem')) {
1733
+ root.querySelectorAll(
1734
+ 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]',
1735
+ ),
1736
+ ).filter(boundEl => {
1737
+ if (boundEl.nodeName !== 'FX-REPEAT') return true;
1738
+
1739
+ const repeatRef = String(boundEl.getAttribute('ref') || '').trim();
1740
+
1741
+ // Any repeat whose ref is a pure parenthesized expression is not a create-nodes candidate.
1742
+ // Example: ('a', 'b', 'c')
1743
+ if (repeatRef.startsWith('(')) return false;
1744
+
1745
+ return isCreateNodesCandidate(repeatRef);
1746
+ });
1747
+
1748
+ if (root.matches && root.matches('fx-repeatitem') && firstNode(root.nodeset)) {
1484
1749
  boundControls.unshift(root);
1485
1750
  }
1751
+
1486
1752
  console.log('_initData', boundControls);
1753
+
1487
1754
  for (let i = 0; i < boundControls.length; i++) {
1488
1755
  const bound = boundControls[i];
1489
1756
 
@@ -1494,11 +1761,21 @@ export class FxFore extends HTMLElement {
1494
1761
  // Repeat items are dumb. They do not respond to evalInContext
1495
1762
  bound.evalInContext();
1496
1763
  }
1497
- if (bound.nodeset !== null && !(Array.isArray(bound.nodeset) && bound.nodeset.length === 0)) {
1498
- // console.log('Node exists', bound.nodeset);
1764
+
1765
+ if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) {
1766
+ continue;
1767
+ }
1768
+ if (isResolvedBound(bound)) {
1769
+ continue;
1770
+ }
1771
+ if (!isCreateNodesCandidate(bound.ref)) {
1772
+ continue;
1773
+ }
1774
+
1775
+ // Ignore bound elements in a different form. They will be taken care of in the other form.
1776
+ if (bound.closest('fx-fore') !== this) {
1499
1777
  continue;
1500
1778
  }
1501
- // console.log('Node does not exists', bound.ref);
1502
1779
 
1503
1780
  // We need to create that node!
1504
1781
  const previousControl = boundControls[i - 1];
@@ -1506,26 +1783,16 @@ export class FxFore extends HTMLElement {
1506
1783
  // Previous control can either be an ancestor of us, or a previous node, which can be a sibling, or a child of a sibling.
1507
1784
  // First: parent
1508
1785
  if (previousControl && previousControl.contains(bound)) {
1509
- // Parent is here.
1510
- // console.log('insert into', bound, previousControl);
1511
- // console.log('insert into nodeset', bound.nodeset);
1512
1786
  /**
1513
1787
  * @type {ParentNode}
1514
1788
  */
1515
- const parentNodeset = previousControl.nodeset;
1516
- // console.log('parentNodeset', parentNodeset);
1517
-
1518
- // const parentModelItemNode = parentModelItem.node;
1789
+ const parentNodeset =
1790
+ firstNode(previousControl.nodeset) || firstNode(root.getModel().getDefaultContext());
1791
+ const creationContext = getCreationContext(bound, parentNodeset);
1519
1792
  const ref = bound.ref;
1520
- // const newElement = parentModelItemNode.ownerDocument.createElement(ref);
1521
- // if (parentNodeset.querySelector(`[ref="${ref}"]`)) {
1522
- // console.log(`Node with ref "${ref}" already exists.`);
1523
- // continue;
1524
- // }
1525
1793
 
1526
- const newNode = this._createNodes(ref, parentNodeset);
1527
- if (!newNode) {
1528
- // We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
1794
+ const newNode = this._createNodes(ref, creationContext || parentNodeset);
1795
+ if (!newNode || !parentNodeset) {
1529
1796
  continue;
1530
1797
  }
1531
1798
  if (newNode.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1543,17 +1810,11 @@ export class FxFore extends HTMLElement {
1543
1810
  // Do not try to get a bind for a nodeSET of a repeat. there are multiple.
1544
1811
  bound.getModelItem().bind?.evalInContext();
1545
1812
  }
1546
-
1547
- // console.log('CREATED child', newElement);
1548
- // console.log('new control evaluated to ', control.nodeset);
1549
- // Done!
1550
1813
  continue;
1551
1814
  }
1552
- // console.log('previousControl', previousControl);
1553
- // console.log('control', control);
1815
+
1554
1816
  // Is previousControl a sibling or a descendant of a logical sibling? Keep looking backwards until we share parents!
1555
- let ourParent = XPathUtil.getParentBindingElement(bound);
1556
- // console.log('ourParent', ourParent);
1817
+ const ourParent = XPathUtil.getParentBindingElement(bound);
1557
1818
  let siblingControl = null;
1558
1819
 
1559
1820
  for (let j = i - 1; j > 0; --j) {
@@ -1566,11 +1827,7 @@ export class FxFore extends HTMLElement {
1566
1827
  break;
1567
1828
  }
1568
1829
  }
1569
- if (!siblingControl) {
1570
- // console.log('No sibling found for', bound);
1571
- }
1572
- // console.log('sibling', siblingControl);
1573
- // todo: review: should this not just be inscopeContext?
1830
+
1574
1831
  let parentNodeset;
1575
1832
  if (!ourParent || !ourParent.nodeset) {
1576
1833
  /*
@@ -1579,13 +1836,13 @@ export class FxFore extends HTMLElement {
1579
1836
  */
1580
1837
  parentNodeset = root.getModel().getDefaultContext();
1581
1838
  } else {
1582
- parentNodeset = ourParent.nodeset;
1839
+ parentNodeset = firstNode(ourParent.nodeset) || root.getModel().getDefaultContext();
1583
1840
  }
1584
1841
  const ref = bound.ref;
1842
+ const creationContext = getCreationContext(bound, parentNodeset);
1585
1843
 
1586
- const newNode = this._createNodes(ref, parentNodeset);
1844
+ const newNode = this._createNodes(ref, creationContext || parentNodeset);
1587
1845
  if (!newNode) {
1588
- // We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
1589
1846
  continue;
1590
1847
  }
1591
1848
 
@@ -1593,13 +1850,12 @@ export class FxFore extends HTMLElement {
1593
1850
  parentNodeset.setAttributeNode(newNode);
1594
1851
  } else {
1595
1852
  let referenceNode = this._findReferenceNodeForNewElement(
1596
- newNode,
1597
- parentNodeset,
1598
- siblingControl,
1853
+ newNode,
1854
+ parentNodeset,
1855
+ siblingControl,
1599
1856
  );
1600
1857
 
1601
1858
  if (referenceNode) {
1602
- // console.log('insert after', referenceNode,newNode);
1603
1859
  if (referenceNode.nodeType === Node.DOCUMENT_NODE) {
1604
1860
  referenceNode.firstElementChild.append(newNode);
1605
1861
  } else {
@@ -1610,50 +1866,135 @@ export class FxFore extends HTMLElement {
1610
1866
  }
1611
1867
  }
1612
1868
 
1613
- /*
1614
- console.log('control inscope', control.getInScopeContext());
1615
- console.log('control ref', control.ref);
1616
- console.log('control new element parent', newElement.parentNode.nodeName);
1617
- */
1618
-
1619
1869
  bound.evalInContext();
1620
1870
  bound.getModelItem().bind?.evalInContext();
1621
1871
 
1622
- if (!bound.nodeset) {
1623
- throw new Error('Creating annode failed');
1872
+ if (!isResolvedBound(bound)) {
1873
+ console.warn('create-nodes: could not resolve bound after node creation, skipping', bound);
1874
+ continue;
1624
1875
  }
1625
- // console.log('new control evaluated to ', control.nodeset);
1626
- // console.log('CREATED sibling', newElement);
1627
1876
  }
1628
- // console.log('DATA', this.getModel().getDefaultContext());
1629
1877
  }
1630
-
1631
1878
  _createNodes(ref, referenceNode) {
1632
- // console.log('creating', ref)
1633
- // console.log('ownerDoc', referenceNode.ownerDocument);
1634
- /*
1635
- const existingNode = evaluateXPathToFirstNode(ref, referenceNode, this);
1636
- if(existingNode){
1637
- console.log(`Node already exists for ref: ${ref}`);
1638
- return existingNode;
1639
- }
1640
- console.log(`creating new node for ref: ${ref}`);
1641
- */
1642
- if (/instance\([^\)]*\)/.test(ref)) {
1643
- // This is an absolute path for some instance. Not supporteed for now
1879
+ if (!ref || !referenceNode) return null;
1880
+
1881
+ const xpath = String(ref).trim();
1882
+ if (!xpath || xpath === '.') return null;
1883
+
1884
+ if (/^instance\([^\)]*\)/.test(xpath)) {
1885
+ // This is an absolute path for some instance. Not supported for create-nodes here.
1644
1886
  return null;
1645
1887
  }
1646
- let newElement;
1647
- if (ref.includes('/')) {
1648
- // multi-step ref expressions
1649
- const namespaceResolver = createNamespaceResolver(ref, this);
1650
- newElement = XPathUtil.createNodesFromXPath(ref, referenceNode.ownerDocument, this, namespaceResolver);
1651
- // console.log('new subtree', newElement);
1652
- return newElement;
1653
- } else {
1654
- const namespaceResolver = createNamespaceResolver(ref, this);
1655
- return XPathUtil.createNodesFromXPath(ref, referenceNode.ownerDocument, this, namespaceResolver);
1888
+
1889
+ const ownerDoc =
1890
+ referenceNode.nodeType === Node.DOCUMENT_NODE ? referenceNode : referenceNode.ownerDocument;
1891
+
1892
+ const baseElement =
1893
+ referenceNode.nodeType === Node.DOCUMENT_NODE
1894
+ ? referenceNode.documentElement
1895
+ : referenceNode.nodeType === Node.ATTRIBUTE_NODE
1896
+ ? referenceNode.ownerElement
1897
+ : referenceNode;
1898
+
1899
+ if (!ownerDoc) return null;
1900
+
1901
+ const baseNamespace = baseElement?.namespaceURI || null;
1902
+ const namespaceResolver = createNamespaceResolver(xpath, this);
1903
+
1904
+ const parseName = token => {
1905
+ const raw = token.trim();
1906
+
1907
+ if (raw.startsWith('@')) {
1908
+ const attrToken = raw.slice(1);
1909
+ if (attrToken.startsWith('*:')) {
1910
+ return { isAttribute: true, namespaceURI: null, localName: attrToken.substring(2) };
1911
+ }
1912
+ if (attrToken.includes(':')) {
1913
+ const [prefix, localName] = attrToken.split(':');
1914
+ return {
1915
+ isAttribute: true,
1916
+ namespaceURI: prefix === '*' ? null : namespaceResolver(prefix) || null,
1917
+ localName,
1918
+ };
1919
+ }
1920
+ return { isAttribute: true, namespaceURI: null, localName: attrToken };
1921
+ }
1922
+
1923
+ if (raw.startsWith('*:')) {
1924
+ return { isAttribute: false, namespaceURI: baseNamespace, localName: raw.substring(2) };
1925
+ }
1926
+ if (raw.includes(':')) {
1927
+ const [prefix, localName] = raw.split(':');
1928
+ return {
1929
+ isAttribute: false,
1930
+ namespaceURI: prefix === '*' ? baseNamespace : namespaceResolver(prefix) || baseNamespace,
1931
+ localName,
1932
+ };
1933
+ }
1934
+ return { isAttribute: false, namespaceURI: baseNamespace, localName: raw };
1935
+ };
1936
+
1937
+ const parseStep = step => {
1938
+ const trimmed = step.trim();
1939
+ const nameMatch = trimmed.match(/^([^\[]+)/);
1940
+ const token = nameMatch ? nameMatch[1].trim() : trimmed;
1941
+ const predicates = [];
1942
+
1943
+ const predicateRegex = /\[\s*@([^\]\s=]+)\s*=\s*(['"])(.*?)\2\s*\]/g;
1944
+ let match;
1945
+ while ((match = predicateRegex.exec(trimmed)) !== null) {
1946
+ predicates.push({ name: match[1], value: match[3] });
1947
+ }
1948
+ return { token, predicates };
1949
+ };
1950
+
1951
+ const steps = xpath
1952
+ .split('/')
1953
+ .map(step => step.trim())
1954
+ .filter(step => step && step !== '.');
1955
+
1956
+ if (!steps.length) return null;
1957
+
1958
+ let subtreeRoot = null;
1959
+ let current = null;
1960
+
1961
+ for (const rawStep of steps) {
1962
+ const { token, predicates } = parseStep(rawStep);
1963
+ if (!token || token === '.') {
1964
+ continue;
1965
+ }
1966
+
1967
+ const parsed = parseName(token);
1968
+
1969
+ if (parsed.isAttribute) {
1970
+ if (!current) {
1971
+ const attr = ownerDoc.createAttribute(parsed.localName);
1972
+ return attr;
1973
+ }
1974
+ current.setAttribute(parsed.localName, '');
1975
+ continue;
1976
+ }
1977
+
1978
+ const element = parsed.namespaceURI
1979
+ ? ownerDoc.createElementNS(parsed.namespaceURI, parsed.localName)
1980
+ : ownerDoc.createElement(parsed.localName);
1981
+
1982
+ for (const predicate of predicates) {
1983
+ const attrName = predicate.name.includes(':')
1984
+ ? predicate.name.split(':')[1]
1985
+ : predicate.name;
1986
+ element.setAttribute(attrName, predicate.value);
1987
+ }
1988
+
1989
+ if (!subtreeRoot) {
1990
+ subtreeRoot = element;
1991
+ } else {
1992
+ current.appendChild(element);
1993
+ }
1994
+ current = element;
1656
1995
  }
1996
+
1997
+ return subtreeRoot;
1657
1998
  }
1658
1999
 
1659
2000
  _handleDragStart(event) {