@jinntec/fore 3.0.1 → 3.1.1

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,18 @@ 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())));
30
+ }
31
+
32
+ /*
33
+ * Determine whether a string is a valid Name
34
+ *
35
+ * @param {string} name
36
+ * @returns {boolean} whether the name is a valid one
37
+ */
38
+ function isValidName(name) {
39
+ const result = new DOMParser().parseFromString(`<${name}/>`, 'application/xml');
40
+ return result.querySelector('parsererror') === null;
28
41
  }
29
42
 
30
43
  /**
@@ -222,8 +235,6 @@ export class FxFore extends HTMLElement {
222
235
  right:0;
223
236
  left:0;
224
237
  height:40px;
225
- border-radius: 5px;
226
-
227
238
  }
228
239
  .popup .close {
229
240
  position: absolute;
@@ -248,6 +259,53 @@ export class FxFore extends HTMLElement {
248
259
  .warning{
249
260
  background:orange;
250
261
  }
262
+ #authoringErrors {
263
+ z-index: 20;
264
+ }
265
+ #authoringErrors .popup {
266
+ width: 70%;
267
+ max-height: 80vh;
268
+ overflow:hidden;
269
+ }
270
+ #authoringErrors h2 {
271
+ background: #c62828;
272
+ color: white;
273
+ padding-left: 12px;
274
+ line-height: 40px;
275
+ font-size: 1rem;
276
+ }
277
+ #authoringErrors table {
278
+ width: 100%;
279
+ border-collapse: collapse;
280
+ font-size: 0.85rem;
281
+ }
282
+ #authoringErrors th {
283
+ text-align: left;
284
+ border-bottom: 2px solid #c62828;
285
+ padding: 4px 8px;
286
+ }
287
+ #authoringErrors td {
288
+ padding: 4px 8px;
289
+ border-bottom: 1px solid #ddd;
290
+ vertical-align: top;
291
+ }
292
+ #authoringErrors td:first-child {
293
+ color: #555;
294
+ font-family: monospace;
295
+ white-space: nowrap;
296
+ }
297
+ #authoringErrors .ae-actions {
298
+ text-align: center;
299
+ margin-top: 12px;
300
+ }
301
+ #authoringErrors .ae-actions button {
302
+ padding: 6px 20px;
303
+ background: #c62828;
304
+ color: white;
305
+ border: none;
306
+ border-radius: 3px;
307
+ cursor: pointer;
308
+ }
251
309
  `;
252
310
 
253
311
  const html = `
@@ -265,6 +323,14 @@ export class FxFore extends HTMLElement {
265
323
  <div id="messageContent"></div>
266
324
  </div>
267
325
  </div>
326
+ <div id="authoringErrors" class="overlay">
327
+ <div class="popup">
328
+ <h2>Authoring Errors</h2>
329
+ <a class="close" href="#" onclick="event.preventDefault();event.target.closest('.overlay').classList.remove('show')">&times;</a>
330
+ <div id="authoringErrorsContent" style="margin-top:48px;"></div>
331
+ <div class="ae-actions"><button onclick="this.closest('.overlay').classList.remove('show')">Dismiss</button></div>
332
+ </div>
333
+ </div>
268
334
  <slot name="event"></slot>
269
335
  `;
270
336
 
@@ -281,8 +347,8 @@ export class FxFore extends HTMLElement {
281
347
  this._scanForNewTemplateExpressionsNextRefresh = false;
282
348
  this.repeatsFromAttributesCreated = false;
283
349
  this.validateOn = this.hasAttribute('validate-on')
284
- ? this.getAttribute('validate-on')
285
- : 'update';
350
+ ? this.getAttribute('validate-on')
351
+ : 'update';
286
352
  // this.mergePartial = this.hasAttribute('merge-partial')? true:false;
287
353
  this.mergePartial = false;
288
354
  this.createNodes = this.hasAttribute('create-nodes') ? true : false;
@@ -305,9 +371,9 @@ export class FxFore extends HTMLElement {
305
371
  _parseTargetList(raw) {
306
372
  if (!raw) return [];
307
373
  return raw
308
- .split(/[\s,]+/)
309
- .map(s => s.trim())
310
- .filter(Boolean);
374
+ .split(/[\s,]+/)
375
+ .map(s => s.trim())
376
+ .filter(Boolean);
311
377
  }
312
378
 
313
379
  _findBySelector(sel) {
@@ -323,10 +389,10 @@ export class FxFore extends HTMLElement {
323
389
 
324
390
  _isReadyTarget(el) {
325
391
  return !!(
326
- el &&
327
- (el.ready === true ||
328
- (el.classList && el.classList.contains('fx-ready')) ||
329
- (typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
392
+ el &&
393
+ (el.ready === true ||
394
+ (el.classList && el.classList.contains('fx-ready')) ||
395
+ (typeof el.hasAttribute === 'function' && el.hasAttribute('ready')))
330
396
  );
331
397
  }
332
398
 
@@ -343,7 +409,7 @@ export class FxFore extends HTMLElement {
343
409
  if (waitForRaw) {
344
410
  if (!this._warnedWaitForDeprecation) {
345
411
  console.warn(
346
- '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
412
+ '[fx-fore] The "wait-for" attribute is deprecated. Use init-on="ready" init-on-target="..." instead.',
347
413
  );
348
414
  this._warnedWaitForDeprecation = true;
349
415
  }
@@ -442,7 +508,7 @@ export class FxFore extends HTMLElement {
442
508
  // Special: closest fx-fore
443
509
  if (targetSpec === 'closest') {
444
510
  const recheckFn =
445
- event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
511
+ event === 'ready' ? () => this._isReadyTarget(this.closest('fx-fore')) : null;
446
512
 
447
513
  const matchesFn = ev => {
448
514
  const t = ev.target;
@@ -456,7 +522,7 @@ export class FxFore extends HTMLElement {
456
522
  const selector = targetSpec;
457
523
 
458
524
  const recheckFn =
459
- event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
525
+ event === 'ready' ? () => this._isReadyTarget(this._findBySelector(selector)) : null;
460
526
 
461
527
  if (typeof recheckFn === 'function' && recheckFn()) {
462
528
  return Promise.resolve();
@@ -489,7 +555,7 @@ export class FxFore extends HTMLElement {
489
555
  }
490
556
 
491
557
  this._initGatesPromise = Promise.all(gates.map(g => this._waitForInitGate(g))).then(
492
- () => undefined,
558
+ () => undefined,
493
559
  );
494
560
  return this._initGatesPromise;
495
561
  }
@@ -524,7 +590,7 @@ export class FxFore extends HTMLElement {
524
590
  }
525
591
  // Fallback for odd engines/polyfills
526
592
  return (slotEl.assignedNodes({ flatten: true }) || []).filter(
527
- n => n.nodeType === Node.ELEMENT_NODE,
593
+ n => n.nodeType === Node.ELEMENT_NODE,
528
594
  );
529
595
  };
530
596
 
@@ -542,8 +608,8 @@ export class FxFore extends HTMLElement {
542
608
  }
543
609
  if (!modelElement.inited) {
544
610
  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%;',
611
+ `%cFore running ... ${this.id ? '#' + this.id : ''}`,
612
+ 'background:#64b5f6; color:white; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
547
613
  );
548
614
 
549
615
  const variables = new Map();
@@ -562,7 +628,7 @@ export class FxFore extends HTMLElement {
562
628
  await Promise.all(libs.map(l => l.readyPromise || Promise.resolve()));
563
629
 
564
630
  await modelElement.modelConstruct();
565
- console.log("varbindings ",this._instanceVarBindings);
631
+ console.log('varbindings ', this._instanceVarBindings);
566
632
  this._handleModelConstructDone();
567
633
  }
568
634
 
@@ -570,7 +636,6 @@ export class FxFore extends HTMLElement {
570
636
  this.inited = true;
571
637
  };
572
638
 
573
-
574
639
  attributeChangedCallback(name, oldValue, newValue) {
575
640
  if (oldValue === newValue) return;
576
641
 
@@ -611,7 +676,7 @@ export class FxFore extends HTMLElement {
611
676
 
612
677
  connectedCallback() {
613
678
  const modelElement = Array.from(this.children).find(
614
- modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
679
+ modelElem => modelElem.nodeName.toUpperCase() === 'FX-MODEL',
615
680
  );
616
681
 
617
682
  this.model = modelElement;
@@ -638,8 +703,8 @@ export class FxFore extends HTMLElement {
638
703
  },true);
639
704
  */
640
705
  this.ignoreExpressions = this.hasAttribute('ignore-expressions')
641
- ? this.getAttribute('ignore-expressions')
642
- : null;
706
+ ? this.getAttribute('ignore-expressions')
707
+ : null;
643
708
 
644
709
  this.lazyRefresh = this.hasAttribute('refresh-on-view');
645
710
  if (this.lazyRefresh) {
@@ -700,9 +765,9 @@ export class FxFore extends HTMLElement {
700
765
 
701
766
  // Collect existing fx-var names at fx-fore scope (author-defined and previously generated)
702
767
  const existingVars = new Set(
703
- Array.from(this.querySelectorAll(':scope > fx-var'))
704
- .map(v => (v.getAttribute('name') || '').trim())
705
- .filter(Boolean),
768
+ Array.from(this.querySelectorAll(':scope > fx-var'))
769
+ .map(v => (v.getAttribute('name') || '').trim())
770
+ .filter(Boolean),
706
771
  );
707
772
 
708
773
  let defaultAssigned = false;
@@ -790,13 +855,13 @@ export class FxFore extends HTMLElement {
790
855
  markAsClean() {
791
856
  console.log('marking as clean', this);
792
857
  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 },
858
+ 'value-changed',
859
+ () => {
860
+ console.log('MARK as modified', this);
861
+ this.dirtyState = dirtyStates.DIRTY;
862
+ this.classList.toggle('fx-modified');
863
+ },
864
+ { once: true },
800
865
  );
801
866
  this.dirtyState = dirtyStates.CLEAN;
802
867
  this.classList.remove('fx-modified');
@@ -894,8 +959,11 @@ export class FxFore extends HTMLElement {
894
959
 
895
960
  try {
896
961
  if (force === true || this.initialRun) {
962
+ performance.mark('force-refresh-start');
897
963
  console.log('🔄 🔴🔴🔴 ### full refresh() on ', this);
898
964
  await Fore.refreshChildren(this, force);
965
+ performance.mark('force-refresh-end');
966
+ performance.measure('force-refresh', 'force-refresh-start', 'force-refresh-end');
899
967
  } else {
900
968
  await this._processBatchedNotifications();
901
969
  }
@@ -912,9 +980,9 @@ export class FxFore extends HTMLElement {
912
980
  this.style.visibility = 'visible';
913
981
 
914
982
  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,
983
+ `%c ✅ refresh-done on #${this.id}`,
984
+ 'background:darkorange; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
985
+ this.getModel().modelItems,
918
986
  );
919
987
 
920
988
  Fore.dispatch(this, 'refresh-done', {});
@@ -954,7 +1022,7 @@ export class FxFore extends HTMLElement {
954
1022
  */
955
1023
  _processBatchedNotifications() {
956
1024
  if (this.batchedNotifications.size > 0) {
957
- console.log(`🔄 🎯 ### processing ${ this.batchedNotifications.size} batched notifications`);
1025
+ console.log(`🔄 🎯 ### processing ${this.batchedNotifications.size} batched notifications`);
958
1026
  console.log('🔄 🎯 ### processing ', Array.from(this.batchedNotifications));
959
1027
 
960
1028
  // console.log(`🔍 Processing ${this.batchedNotifications.size} batched notifications`);
@@ -1016,7 +1084,7 @@ export class FxFore extends HTMLElement {
1016
1084
  */
1017
1085
  _updateTemplateExpressions() {
1018
1086
  const search =
1019
- "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
1087
+ "(descendant-or-self::*!(text(), @*))[contains(., '{')][substring-after(., '{') => contains('}')][not(ancestor-or-self::*[self::fx-model or self::fx-function])]";
1020
1088
 
1021
1089
  const tmplExpressions = evaluateXPathToNodes(search, this, this);
1022
1090
  // console.log('template expressions found ', tmplExpressions);
@@ -1027,7 +1095,7 @@ export class FxFore extends HTMLElement {
1027
1095
 
1028
1096
  // console.log('######### storedTemplateExpressions', this.storedTemplateExpressions.length);
1029
1097
 
1030
- if(!tmplExpressions) return;
1098
+ if (!tmplExpressions) return;
1031
1099
  /*
1032
1100
  storing expressions and their nodes for re-evaluation
1033
1101
  */
@@ -1104,7 +1172,7 @@ export class FxFore extends HTMLElement {
1104
1172
  if (el && el.localName === 'fx-insert' && node.name === 'origin') {
1105
1173
  const v = String(node.value ?? '').trim();
1106
1174
  const isJsonLiteral =
1107
- (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
1175
+ (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'));
1108
1176
  if (isJsonLiteral) return;
1109
1177
  }
1110
1178
  }
@@ -1115,16 +1183,16 @@ export class FxFore extends HTMLElement {
1115
1183
  // - fx-var scoping (in-scope variables)
1116
1184
  // - context() in repeats (repeat item detection)
1117
1185
  const definitionElement =
1118
- node.nodeType === Node.ATTRIBUTE_NODE
1119
- ? node.ownerElement
1120
- : node.nodeType === Node.TEXT_NODE
1121
- ? (node.parentElement || node.parentNode)
1122
- : node;
1186
+ node.nodeType === Node.ATTRIBUTE_NODE
1187
+ ? node.ownerElement
1188
+ : node.nodeType === Node.TEXT_NODE
1189
+ ? node.parentElement || node.parentNode
1190
+ : node;
1123
1191
 
1124
1192
  const formElement =
1125
- definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
1126
- ? definitionElement
1127
- : this;
1193
+ definitionElement && definitionElement.nodeType === Node.ELEMENT_NODE
1194
+ ? definitionElement
1195
+ : this;
1128
1196
 
1129
1197
  const replaced = String(expr ?? '').replace(/{[^}]*}/g, match => {
1130
1198
  if (match === '{}') return match;
@@ -1159,7 +1227,7 @@ export class FxFore extends HTMLElement {
1159
1227
  node.textContent = replaced;
1160
1228
  }
1161
1229
  }
1162
- } // eslint-disable-next-line class-methods-use-this
1230
+ } // eslint-disable-next-line class-methods-use-this
1163
1231
  _getTemplateExpression(node) {
1164
1232
  if (this.ignoredNodes) {
1165
1233
  if (node.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1208,17 +1276,17 @@ export class FxFore extends HTMLElement {
1208
1276
 
1209
1277
  // ##### lazy creation should NOT take place if there's a parent Fore using shared instances
1210
1278
  const parentFore =
1211
- this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
1212
- ? this.parentNode.closest('fx-fore')
1213
- : null;
1279
+ this.parentNode.nodeType !== Node.DOCUMENT_FRAGMENT_NODE
1280
+ ? this.parentNode.closest('fx-fore')
1281
+ : null;
1214
1282
  if (this.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
1215
1283
  console.log('fragment', this.parentNode);
1216
1284
  }
1217
1285
 
1218
1286
  if (parentFore) {
1219
1287
  const shared = parentFore
1220
- .getModel()
1221
- .instances.filter(shared => shared.hasAttribute('shared'));
1288
+ .getModel()
1289
+ .instances.filter(shared => shared.hasAttribute('shared'));
1222
1290
  if (shared.length !== 0) return;
1223
1291
  }
1224
1292
 
@@ -1239,8 +1307,8 @@ export class FxFore extends HTMLElement {
1239
1307
  }
1240
1308
  } catch (e) {
1241
1309
  console.warn(
1242
- 'lazyCreateInstance created an error attempting to create a document',
1243
- e.message,
1310
+ 'lazyCreateInstance created an error attempting to create a document',
1311
+ e.message,
1244
1312
  );
1245
1313
  }
1246
1314
  }
@@ -1297,8 +1365,8 @@ export class FxFore extends HTMLElement {
1297
1365
  */
1298
1366
  async _initUI() {
1299
1367
  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%;',
1368
+ `%cinitUI #${this.id}`,
1369
+ 'background:lightblue; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1302
1370
  );
1303
1371
 
1304
1372
  const parentFore = this.closest('fx-fore');
@@ -1320,7 +1388,12 @@ export class FxFore extends HTMLElement {
1320
1388
 
1321
1389
  // First refresh should be forced
1322
1390
  if (this.createNodes) {
1391
+ performance.mark('initData-start');
1323
1392
  this.initData();
1393
+ performance.mark('initData-end');
1394
+
1395
+ performance.measure('initData', 'initData-start', 'initData-end');
1396
+
1324
1397
  const binds = this.getModel().querySelector('fx-bind');
1325
1398
  if (binds) {
1326
1399
  this.getModel().updateModel();
@@ -1338,13 +1411,12 @@ export class FxFore extends HTMLElement {
1338
1411
  this.initialRun = false;
1339
1412
  // console.log('### >>>>> dispatching ready >>>>>', this);
1340
1413
  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%;',
1414
+ `%c ✅ ${this.id ? '#' + this.id : 'Fore'} is ready`,
1415
+ 'background:lightgreen; color:black; padding:.5rem; display:inline-block; white-space: nowrap; border-radius:0.3rem;width:100%;',
1343
1416
  );
1344
1417
 
1345
1418
  // console.log(`### <<<<< ${this.id} ready >>>>>`);
1346
1419
 
1347
- // console.log('### modelItems: ', this.getModel().modelItems);
1348
1420
  Fore.dispatch(this, 'ready', {});
1349
1421
  // console.log('dataChanged', FxModel.dataChanged);
1350
1422
  this.markAsClean();
@@ -1358,6 +1430,50 @@ export class FxFore extends HTMLElement {
1358
1430
  e.stopPropagation();
1359
1431
  e.dataTransfer.dropEffect = 'move';
1360
1432
  });
1433
+
1434
+ // Run authoring checks after ready — they're diagnostic only and must not delay
1435
+ // the ready event or drag-listener registration (both of which tests depend on).
1436
+ try {
1437
+ await this._runAuthoringChecks();
1438
+ } catch (e) {
1439
+ console.warn('[fore] authoring check failed:', e.message);
1440
+ }
1441
+ }
1442
+
1443
+ async _runAuthoringChecks() {
1444
+ if (this.hasAttribute('no-check')) return;
1445
+ if (new URLSearchParams(window.location.search).has('no-check')) return;
1446
+
1447
+ const { checkAuthoring } = await import('./authoring-check.js');
1448
+ const errors = checkAuthoring(this);
1449
+ if (errors.length) {
1450
+ this._showAuthoringErrors(errors);
1451
+ }
1452
+ }
1453
+
1454
+ _showAuthoringErrors(errors) {
1455
+ const overlay = this.shadowRoot.getElementById('authoringErrors');
1456
+ const content = this.shadowRoot.getElementById('authoringErrorsContent');
1457
+ if (!overlay || !content) return;
1458
+
1459
+ const rows = errors
1460
+ .map(({ element, message }) => {
1461
+ const path = element
1462
+ ? element.tagName.toLowerCase() + (element.id ? `#${element.id}` : '')
1463
+ : '?';
1464
+ const safeMsg = message.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1465
+ const safePath = path.replace(/</g, '&lt;').replace(/>/g, '&gt;');
1466
+ return `<tr><td>${safePath}</td><td>${safeMsg}</td></tr>`;
1467
+ })
1468
+ .join('');
1469
+
1470
+ content.innerHTML = `
1471
+ <table>
1472
+ <thead><tr><th>Element</th><th>Problem</th></tr></thead>
1473
+ <tbody>${rows}</tbody>
1474
+ </table>`;
1475
+
1476
+ overlay.classList.add('show');
1361
1477
  }
1362
1478
 
1363
1479
  /**
@@ -1438,7 +1554,7 @@ export class FxFore extends HTMLElement {
1438
1554
  const lastMatchingSibling = nodeset.reverse().find(node => parentElement.contains(node));
1439
1555
  if (lastMatchingSibling) {
1440
1556
  return lastMatchingSibling;
1441
- }
1557
+ }
1442
1558
  // Otherwise, just default to appending... If this runs multiple times for multiple nodes
1443
1559
  // it's unexpected to always prepend and get the order of children reversed from the UI.
1444
1560
 
@@ -1465,25 +1581,187 @@ export class FxFore extends HTMLElement {
1465
1581
  }
1466
1582
  /**
1467
1583
  * @param {HTMLElement} root The root of the data initialization. fx-repeat overrides this when it makes new repeat items
1468
- *
1469
1584
  */
1470
1585
  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'));
1586
+ /**
1587
+ * @param {*} value
1588
+ * @returns {boolean}
1589
+ */
1590
+ const isObjectLike = value =>
1591
+ value !== null && (typeof value === 'object' || typeof value === 'function');
1592
+
1593
+ /**
1594
+ * @param {*} nodeset
1595
+ * @returns {boolean}
1596
+ */
1597
+ const hasResolvedNodeset = nodeset => {
1598
+ if (nodeset == null) return false;
1599
+
1600
+ // Atomic values like strings/numbers/booleans are valid resolved XPath results
1601
+ if (!isObjectLike(nodeset)) return true;
1602
+
1603
+ if (Array.isArray(nodeset)) return nodeset.length > 0;
1604
+
1605
+ if (typeof nodeset.length === 'number' && !('nodeType' in nodeset)) {
1606
+ return nodeset.length > 0;
1607
+ }
1608
+
1609
+ if (typeof nodeset[Symbol.iterator] === 'function' && !('nodeType' in nodeset)) {
1610
+ for (const item of nodeset) {
1611
+ return item !== undefined;
1612
+ }
1613
+ return false;
1614
+ }
1615
+
1616
+ return !!nodeset.nodeType;
1617
+ };
1618
+
1619
+ /**
1620
+ * Only try create-nodes for path-like refs, not general expressions like sequences.
1621
+ * @param {string} ref
1622
+ * @returns {boolean}
1623
+ */
1624
+ const isCreateNodesCandidate = ref => {
1625
+ const expr = String(ref || '').trim();
1626
+ if (!expr || expr === '.') return false;
1627
+ if (expr.startsWith('"') || expr.startsWith("'")) return false;
1628
+
1629
+ // Ignore only simple literal sequences like ('a', 'b', 'c') or (1, 2, 3).
1630
+ // Keep fx-repeat refs that are real path expressions or more complex XPath.
1631
+ const simpleSequencePattern =
1632
+ /^\(\s*(?:(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.)(?:\s*,\s*(?:'[^']*'|"[^"]*"|\d+(?:\.\d+)?|\.))*)?\s*\)$/;
1633
+ if (simpleSequencePattern.test(expr)) return false;
1634
+
1635
+ return true;
1636
+ };
1637
+
1638
+ /**
1639
+ * Detect whether a ref ends in an attribute step.
1640
+ * @param {string} ref
1641
+ * @returns {boolean}
1642
+ */
1643
+ const isAttributeRef = ref => /(^|\/)\s*@/.test(String(ref || '').trim());
1644
+
1645
+ /**
1646
+ * Normalize a possibly sequence-like nodeset/context to a single DOM node.
1647
+ * @param {*} candidate
1648
+ * @returns {*}
1649
+ */
1650
+ const firstNode = candidate => {
1651
+ if (!candidate) return null;
1652
+ if (!isObjectLike(candidate)) return null;
1653
+ if (candidate.nodeType) return candidate;
1654
+
1655
+ if (Array.isArray(candidate)) {
1656
+ return candidate.find(item => item && isObjectLike(item) && item.nodeType) || null;
1657
+ }
1658
+
1659
+ if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
1660
+ for (let i = 0; i < candidate.length; i += 1) {
1661
+ const item = candidate.item(i);
1662
+ if (item && isObjectLike(item) && item.nodeType) return item;
1663
+ }
1664
+ }
1665
+
1666
+ return null;
1667
+ };
1668
+
1669
+ /**
1670
+ * Detect XPath results that are sequences of atomic values rather than DOM nodes.
1671
+ * These are valid resolved results for repeats, but they must never trigger create-nodes.
1672
+ * @param {*} candidate
1673
+ * @returns {boolean}
1674
+ */
1675
+ const isAtomicSequence = candidate => {
1676
+ if (!candidate) return false;
1677
+
1678
+ // A single primitive value is also atomic from our perspective
1679
+ if (!isObjectLike(candidate)) return true;
1680
+
1681
+ if (candidate.nodeType) return false;
1682
+
1683
+ if (Array.isArray(candidate)) {
1684
+ return (
1685
+ candidate.length > 0 &&
1686
+ !candidate.some(item => item && isObjectLike(item) && item.nodeType)
1687
+ );
1688
+ }
1689
+
1690
+ if (typeof candidate.length === 'number' && typeof candidate.item === 'function') {
1691
+ return false;
1692
+ }
1693
+
1694
+ if (typeof candidate[Symbol.iterator] === 'function') {
1695
+ for (const item of candidate) {
1696
+ return !(item && isObjectLike(item) && item.nodeType);
1697
+ }
1698
+ }
1699
+
1700
+ return false;
1701
+ };
1702
+
1703
+ /**
1704
+ * Check whether a bound element is resolved after evalInContext.
1705
+ * Attribute refs often expose an empty string as nodeset, so use the model item node in that case.
1706
+ * @param {import('./ForeElementMixin.js').default} bound
1707
+ * @returns {boolean}
1708
+ */
1709
+ const isResolvedBound = bound => {
1710
+ if (hasResolvedNodeset(bound.nodeset)) return true;
1711
+ if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) return true;
1712
+ if (isAttributeRef(bound.ref)) {
1713
+ const modelItem = typeof bound.getModelItem === 'function' ? bound.getModelItem() : null;
1714
+ return !!modelItem?.node;
1715
+ }
1716
+ return false;
1717
+ };
1718
+
1719
+ /**
1720
+ * Determine the best context node for lazy node creation.
1721
+ * Prefer the bound element's in-scope context, but fall back to a structural parent.
1722
+ * @param {import('./ForeElementMixin.js').default} bound
1723
+ * @param {*} fallback
1724
+ * @returns {*}
1725
+ */
1726
+ const getCreationContext = (bound, fallback) => {
1727
+ const direct =
1728
+ typeof bound.getInScopeContext === 'function' ? firstNode(bound.getInScopeContext()) : null;
1729
+ if (direct) return direct;
1730
+
1731
+ const dotCtx = firstNode(getInScopeContext(bound, '.'));
1732
+ if (dotCtx) return dotCtx;
1733
+
1734
+ const refCtx = firstNode(getInScopeContext(bound, bound.ref));
1735
+ if (refCtx) return refCtx;
1736
+
1737
+ return firstNode(fallback);
1738
+ };
1474
1739
 
1475
1740
  /**
1476
1741
  * @type {import('./ForeElementMixin.js').default[]}
1477
1742
  */
1478
1743
  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')) {
1744
+ root.querySelectorAll(
1745
+ 'fx-control[ref],fx-upload[ref],fx-group[ref],fx-repeat[ref], fx-switch[ref]',
1746
+ ),
1747
+ ).filter(boundEl => {
1748
+ if (boundEl.nodeName !== 'FX-REPEAT') return true;
1749
+
1750
+ const repeatRef = String(boundEl.getAttribute('ref') || '').trim();
1751
+
1752
+ // Any repeat whose ref is a pure parenthesized expression is not a create-nodes candidate.
1753
+ // Example: ('a', 'b', 'c')
1754
+ if (repeatRef.startsWith('(')) return false;
1755
+
1756
+ return isCreateNodesCandidate(repeatRef);
1757
+ });
1758
+
1759
+ if (root.matches && root.matches('fx-repeatitem') && firstNode(root.nodeset)) {
1484
1760
  boundControls.unshift(root);
1485
1761
  }
1762
+
1486
1763
  console.log('_initData', boundControls);
1764
+
1487
1765
  for (let i = 0; i < boundControls.length; i++) {
1488
1766
  const bound = boundControls[i];
1489
1767
 
@@ -1494,11 +1772,21 @@ export class FxFore extends HTMLElement {
1494
1772
  // Repeat items are dumb. They do not respond to evalInContext
1495
1773
  bound.evalInContext();
1496
1774
  }
1497
- if (bound.nodeset !== null && !(Array.isArray(bound.nodeset) && bound.nodeset.length === 0)) {
1498
- // console.log('Node exists', bound.nodeset);
1775
+
1776
+ if (bound.nodeName === 'FX-REPEAT' && isAtomicSequence(bound.nodeset)) {
1777
+ continue;
1778
+ }
1779
+ if (isResolvedBound(bound)) {
1780
+ continue;
1781
+ }
1782
+ if (!isCreateNodesCandidate(bound.ref)) {
1783
+ continue;
1784
+ }
1785
+
1786
+ // Ignore bound elements in a different form. They will be taken care of in the other form.
1787
+ if (bound.closest('fx-fore') !== this) {
1499
1788
  continue;
1500
1789
  }
1501
- // console.log('Node does not exists', bound.ref);
1502
1790
 
1503
1791
  // We need to create that node!
1504
1792
  const previousControl = boundControls[i - 1];
@@ -1506,26 +1794,16 @@ export class FxFore extends HTMLElement {
1506
1794
  // 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
1795
  // First: parent
1508
1796
  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
1797
  /**
1513
1798
  * @type {ParentNode}
1514
1799
  */
1515
- const parentNodeset = previousControl.nodeset;
1516
- // console.log('parentNodeset', parentNodeset);
1517
-
1518
- // const parentModelItemNode = parentModelItem.node;
1800
+ const parentNodeset =
1801
+ firstNode(previousControl.nodeset) || firstNode(root.getModel().getDefaultContext());
1802
+ const creationContext = getCreationContext(bound, parentNodeset);
1519
1803
  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
1804
 
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')`?
1805
+ const newNode = this._createNodes(ref, creationContext || parentNodeset);
1806
+ if (!newNode || !parentNodeset) {
1529
1807
  continue;
1530
1808
  }
1531
1809
  if (newNode.nodeType === Node.ATTRIBUTE_NODE) {
@@ -1543,17 +1821,11 @@ export class FxFore extends HTMLElement {
1543
1821
  // Do not try to get a bind for a nodeSET of a repeat. there are multiple.
1544
1822
  bound.getModelItem().bind?.evalInContext();
1545
1823
  }
1546
-
1547
- // console.log('CREATED child', newElement);
1548
- // console.log('new control evaluated to ', control.nodeset);
1549
- // Done!
1550
1824
  continue;
1551
1825
  }
1552
- // console.log('previousControl', previousControl);
1553
- // console.log('control', control);
1826
+
1554
1827
  // 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);
1828
+ const ourParent = XPathUtil.getParentBindingElement(bound);
1557
1829
  let siblingControl = null;
1558
1830
 
1559
1831
  for (let j = i - 1; j > 0; --j) {
@@ -1566,11 +1838,7 @@ export class FxFore extends HTMLElement {
1566
1838
  break;
1567
1839
  }
1568
1840
  }
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?
1841
+
1574
1842
  let parentNodeset;
1575
1843
  if (!ourParent || !ourParent.nodeset) {
1576
1844
  /*
@@ -1579,13 +1847,13 @@ export class FxFore extends HTMLElement {
1579
1847
  */
1580
1848
  parentNodeset = root.getModel().getDefaultContext();
1581
1849
  } else {
1582
- parentNodeset = ourParent.nodeset;
1850
+ parentNodeset = firstNode(ourParent.nodeset) || root.getModel().getDefaultContext();
1583
1851
  }
1584
1852
  const ref = bound.ref;
1853
+ const creationContext = getCreationContext(bound, parentNodeset);
1585
1854
 
1586
- const newNode = this._createNodes(ref, parentNodeset);
1855
+ const newNode = this._createNodes(ref, creationContext || parentNodeset);
1587
1856
  if (!newNode) {
1588
- // We could not make the node for some reason. Maybe it's something like `instance('XXX')`?
1589
1857
  continue;
1590
1858
  }
1591
1859
 
@@ -1593,13 +1861,12 @@ export class FxFore extends HTMLElement {
1593
1861
  parentNodeset.setAttributeNode(newNode);
1594
1862
  } else {
1595
1863
  let referenceNode = this._findReferenceNodeForNewElement(
1596
- newNode,
1597
- parentNodeset,
1598
- siblingControl,
1864
+ newNode,
1865
+ parentNodeset,
1866
+ siblingControl,
1599
1867
  );
1600
1868
 
1601
1869
  if (referenceNode) {
1602
- // console.log('insert after', referenceNode,newNode);
1603
1870
  if (referenceNode.nodeType === Node.DOCUMENT_NODE) {
1604
1871
  referenceNode.firstElementChild.append(newNode);
1605
1872
  } else {
@@ -1610,50 +1877,182 @@ export class FxFore extends HTMLElement {
1610
1877
  }
1611
1878
  }
1612
1879
 
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
1880
  bound.evalInContext();
1620
1881
  bound.getModelItem().bind?.evalInContext();
1621
1882
 
1622
- if (!bound.nodeset) {
1623
- throw new Error('Creating annode failed');
1883
+ if (!isResolvedBound(bound)) {
1884
+ console.warn('create-nodes: could not resolve bound after node creation, skipping', bound);
1885
+ continue;
1624
1886
  }
1625
- // console.log('new control evaluated to ', control.nodeset);
1626
- // console.log('CREATED sibling', newElement);
1627
1887
  }
1628
- // console.log('DATA', this.getModel().getDefaultContext());
1629
1888
  }
1630
-
1631
1889
  _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
1890
+ if (!ref || !referenceNode) return null;
1891
+
1892
+ const xpath = String(ref).trim();
1893
+ if (!xpath || xpath === '.') return null;
1894
+
1895
+ if (/^instance\([^\)]*\)/.test(xpath)) {
1896
+ // This is an absolute path for some instance. Not supported for create-nodes here.
1644
1897
  return null;
1645
1898
  }
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);
1899
+
1900
+ const ownerDoc =
1901
+ referenceNode.nodeType === Node.DOCUMENT_NODE ? referenceNode : referenceNode.ownerDocument;
1902
+
1903
+ const baseElement =
1904
+ referenceNode.nodeType === Node.DOCUMENT_NODE
1905
+ ? referenceNode.documentElement
1906
+ : referenceNode.nodeType === Node.ATTRIBUTE_NODE
1907
+ ? referenceNode.ownerElement
1908
+ : referenceNode;
1909
+
1910
+ if (!ownerDoc) return null;
1911
+
1912
+ const baseNamespace = baseElement?.namespaceURI || null;
1913
+ const namespaceResolver = createNamespaceResolver(xpath, this);
1914
+
1915
+ const parseName = token => {
1916
+ const raw = token.trim();
1917
+
1918
+ if (raw.startsWith('@')) {
1919
+ const attrToken = raw.slice(1);
1920
+ if (attrToken.startsWith('*:')) {
1921
+ return { isAttribute: true, namespaceURI: null, localName: attrToken.substring(2) };
1922
+ }
1923
+ if (attrToken.includes(':')) {
1924
+ const [prefix, localName] = attrToken.split(':');
1925
+ return {
1926
+ isAttribute: true,
1927
+ namespaceURI: prefix === '*' ? null : namespaceResolver(prefix) || null,
1928
+ localName,
1929
+ };
1930
+ }
1931
+ return { isAttribute: true, namespaceURI: null, localName: attrToken };
1932
+ }
1933
+
1934
+ if (raw.startsWith('*:')) {
1935
+ return { isAttribute: false, namespaceURI: baseNamespace, localName: raw.substring(2) };
1936
+ }
1937
+ if (raw.includes(':')) {
1938
+ const [prefix, localName] = raw.split(':');
1939
+ return {
1940
+ isAttribute: false,
1941
+ namespaceURI: prefix === '*' ? baseNamespace : namespaceResolver(prefix) || baseNamespace,
1942
+ localName,
1943
+ };
1944
+ }
1945
+ return { isAttribute: false, namespaceURI: baseNamespace, localName: raw };
1946
+ };
1947
+
1948
+ const parseStep = step => {
1949
+ const trimmed = step.trim();
1950
+ const nameMatch = trimmed.match(/^([^\[]+)/);
1951
+ const token = nameMatch ? nameMatch[1].trim() : trimmed;
1952
+ const predicates = [];
1953
+
1954
+ const predicateRegex = /\[\s*@([^\]\s=]+)\s*=\s*(['"])(.*?)\2\s*\]/g;
1955
+ let match;
1956
+ while ((match = predicateRegex.exec(trimmed)) !== null) {
1957
+ predicates.push({ name: match[1], value: match[3] });
1958
+ }
1959
+ return { token, predicates };
1960
+ };
1961
+
1962
+ const splitSteps = xpath => {
1963
+ /**
1964
+ * @type {string[]}
1965
+ */
1966
+ const steps = [];
1967
+ let scratch = '';
1968
+ let isInPredicate = false;
1969
+ for (const char of xpath.split('')) {
1970
+ if (char === '[') {
1971
+ isInPredicate = true;
1972
+ scratch += char;
1973
+ continue;
1974
+ }
1975
+ if (char === ']') {
1976
+ scratch += char;
1977
+ isInPredicate = false;
1978
+ continue;
1979
+ }
1980
+ if (!isInPredicate) {
1981
+ // Just add to the scratch. Do not check for slashes within predicates
1982
+ if (char === '/') {
1983
+ // Consume this path step
1984
+ if (scratch) {
1985
+ steps.push(scratch);
1986
+ }
1987
+ scratch = '';
1988
+ continue;
1989
+ }
1990
+ }
1991
+ scratch += char;
1992
+ }
1993
+
1994
+ if (scratch) {
1995
+ // Flush it
1996
+ steps.push(scratch);
1997
+ }
1998
+
1999
+ return steps;
2000
+ };
2001
+
2002
+ const steps = splitSteps(xpath)
2003
+ .map(step => step.trim())
2004
+ .filter(step => step && step !== '.');
2005
+
2006
+ if (!steps.length) return null;
2007
+
2008
+ let subtreeRoot = null;
2009
+ let current = null;
2010
+
2011
+ for (const rawStep of steps) {
2012
+ const { token, predicates } = parseStep(rawStep);
2013
+ if (!token || token === '.') {
2014
+ continue;
2015
+ }
2016
+
2017
+ const parsed = parseName(token);
2018
+
2019
+ if (parsed.isAttribute) {
2020
+ if (!current) {
2021
+ const attr = ownerDoc.createAttribute(parsed.localName);
2022
+ return attr;
2023
+ }
2024
+ current.setAttribute(parsed.localName, '');
2025
+ continue;
2026
+ }
2027
+
2028
+ if (!isValidName(parsed.localName)) {
2029
+ // This did not result in a valid name. Stop.
2030
+ console.warn(
2031
+ `Creating node for the XPath ${xpath} failed because the part ${parsed.localName} is not a valid Name.`,
2032
+ );
2033
+ return;
2034
+ }
2035
+
2036
+ const element = parsed.namespaceURI
2037
+ ? ownerDoc.createElementNS(parsed.namespaceURI, parsed.localName)
2038
+ : ownerDoc.createElement(parsed.localName);
2039
+
2040
+ for (const predicate of predicates) {
2041
+ const attrName = predicate.name.includes(':')
2042
+ ? predicate.name.split(':')[1]
2043
+ : predicate.name;
2044
+ element.setAttribute(attrName, predicate.value);
2045
+ }
2046
+
2047
+ if (!subtreeRoot) {
2048
+ subtreeRoot = element;
2049
+ } else {
2050
+ current.appendChild(element);
2051
+ }
2052
+ current = element;
1656
2053
  }
2054
+
2055
+ return subtreeRoot;
1657
2056
  }
1658
2057
 
1659
2058
  _handleDragStart(event) {