@jinntec/fore 3.3.2 → 4.0.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 (73) hide show
  1. package/README.md +98 -70
  2. package/dist/fore-dev.js +5593 -6832
  3. package/dist/fore.js +5602 -4887
  4. package/index.js +5 -10
  5. package/package.json +7 -7
  6. package/resources/fore.css +33 -0
  7. package/src/DependencyNotifyingDomFacade.js +90 -21
  8. package/src/DependentXPathQueries.js +15 -2
  9. package/src/ForeElementMixin.js +110 -16
  10. package/src/UndoManager.js +267 -0
  11. package/src/actions/abstract-action.js +71 -30
  12. package/src/actions/fx-action.js +5 -0
  13. package/src/actions/fx-append.js +3 -3
  14. package/src/actions/fx-commit-history.js +26 -0
  15. package/src/actions/fx-hide.js +1 -1
  16. package/src/actions/fx-insert.js +25 -22
  17. package/src/actions/fx-load.js +5 -5
  18. package/src/actions/fx-redo.js +58 -0
  19. package/src/actions/fx-refresh.js +2 -2
  20. package/src/actions/fx-reload.js +1 -1
  21. package/src/actions/fx-replace.js +1 -1
  22. package/src/actions/fx-send.js +27 -5
  23. package/src/actions/fx-setattribute.js +11 -7
  24. package/src/actions/fx-undo.js +58 -0
  25. package/src/createNodes.js +314 -0
  26. package/src/fore.js +53 -18
  27. package/src/functions/fx-functionlib.js +10 -10
  28. package/src/fx-bind.js +30 -18
  29. package/src/fx-fore.js +222 -200
  30. package/src/fx-instance.js +18 -1
  31. package/src/fx-model.js +236 -69
  32. package/src/fx-submission.js +37 -29
  33. package/src/fx-var.js +49 -13
  34. package/src/getInScopeContext.js +1 -1
  35. package/src/json/JSONDomFacade.js +1 -1
  36. package/src/json/JSONLens.js +2 -2
  37. package/src/ui/UIElement.js +18 -8
  38. package/src/ui/abstract-control.js +45 -3
  39. package/src/ui/fx-alert.js +4 -0
  40. package/src/ui/fx-case.js +1 -1
  41. package/src/ui/fx-container.js +3 -0
  42. package/src/ui/fx-control-menu.js +79 -11
  43. package/src/ui/fx-control.js +130 -41
  44. package/src/ui/fx-dialog.js +5 -0
  45. package/src/ui/fx-items.js +6 -6
  46. package/src/ui/fx-output.js +37 -1
  47. package/src/ui/fx-repeat.js +1065 -103
  48. package/src/ui/fx-repeatitem.js +4 -1
  49. package/src/ui/fx-switch.js +116 -3
  50. package/src/ui/fx-trigger.js +9 -4
  51. package/src/ui/fx-upload.js +10 -4
  52. package/src/ui/repeat-base.js +20 -12
  53. package/src/withDraggability.js +10 -1
  54. package/src/xpath-evaluation.js +30 -18
  55. package/src/xpath-path.js +122 -0
  56. package/src/xpath-util.js +11 -126
  57. package/src/actions/StringTpl.js +0 -17
  58. package/src/extract-predicate-deps.js +0 -57
  59. package/src/extractPredicateDependencies.js +0 -36
  60. package/src/json/lensFromNode.js +0 -5
  61. package/src/json-util.js +0 -27
  62. package/src/tools/adi.js +0 -1111
  63. package/src/tools/deprecation.md +0 -1
  64. package/src/tools/fx-action-log.js +0 -745
  65. package/src/tools/fx-devtools.js +0 -444
  66. package/src/tools/fx-dom-inspector.js +0 -610
  67. package/src/tools/fx-json-instance.js +0 -444
  68. package/src/tools/fx-log-item.js +0 -128
  69. package/src/tools/fx-log-settings.js +0 -533
  70. package/src/tools/fx-minimap.js +0 -203
  71. package/src/tools/helpers.js +0 -132
  72. package/src/ui/TemplateExpression.js +0 -12
  73. package/src/ui/fx-dom-inspector.js +0 -1255
@@ -54,7 +54,10 @@ export class FxRepeatitem extends withDraggability(UIElement, true) {
54
54
  // Its children already get their context from the repeatitem via getInScopeContext().
55
55
  this.ref = `${this.parentNode.ref}`;
56
56
 
57
- this.tabindex = 0;
57
+ this.setAttribute('role', 'listitem');
58
+ // `tabindex` (lowercase) is not a reflected IDL property - assigning it was a silent no-op.
59
+ // Use the actual attribute so repeat items are keyboard-focusable.
60
+ this.setAttribute('tabindex', '0');
58
61
  }
59
62
 
60
63
  disconnectedCallback() {
@@ -21,20 +21,29 @@ class FxSwitch extends FxContainer {
21
21
  this.selectedCase = null;
22
22
  this.cases = null;
23
23
  this.visitedResetted = false;
24
+ this.tabTriggers = [];
24
25
  }
25
26
 
26
27
  connectedCallback() {
27
28
  if (super.connectedCallback) {
28
29
  super.connectedCallback();
29
30
  }
31
+ // Opt-in ARIA tabs pattern (role="tablist"/"tab"/"tabpanel", aria-selected, arrow-key
32
+ // navigation). Requires `fx-trigger` tab controls (each with an `fx-toggle` child targeting
33
+ // a sibling `fx-case`) to be direct children, so a valid tablist can be assembled - see
34
+ // ACCESSIBILITY.md. Plain fx-switch usage (bound switches, wizards, accordions built from
35
+ // interleaved trigger/case pairs) is unaffected.
36
+ this.tabsMode = this.getAttribute('appearance') === 'tabs';
37
+
30
38
  const style = `
31
39
  :host ::slotted(fx-case.selected-case){
32
40
  display: block;
33
41
  }
34
42
  `;
35
- const html = `
36
- <slot></slot>
37
- `;
43
+ const html = this.tabsMode
44
+ ? `<div part="tablist" role="tablist"><slot name="tab"></slot></div>
45
+ <slot></slot>`
46
+ : `<slot></slot>`;
38
47
  this.shadowRoot.innerHTML = `
39
48
  <style>
40
49
  ${style}
@@ -44,6 +53,11 @@ class FxSwitch extends FxContainer {
44
53
  this.cases = [];
45
54
  this.formerCase = null;
46
55
  this.selectedCase = null;
56
+ this.tabTriggers = [];
57
+
58
+ if (this.tabsMode) {
59
+ this.addEventListener('keydown', e => this._handleTabKeydown(e));
60
+ }
47
61
  }
48
62
 
49
63
  async refresh(force) {
@@ -61,9 +75,104 @@ class FxSwitch extends FxContainer {
61
75
  this.toggle(this.selectedCase);
62
76
  }
63
77
 
78
+ if (this.tabsMode) {
79
+ this._setupTabs();
80
+ }
81
+
64
82
  Fore.refreshChildren(this.selectedCase, force);
65
83
  }
66
84
 
85
+ /**
86
+ * Discovers direct-child `fx-trigger`/`fx-toggle` pairs that target this switch's cases and
87
+ * wires them up as an ARIA tab strip. Runs discovery once (mirroring the `this.cases` caching
88
+ * above); subsequent calls just re-sync the selected state.
89
+ */
90
+ _setupTabs() {
91
+ if (this.tabTriggers.length > 0) {
92
+ this._syncTabSelection();
93
+ return;
94
+ }
95
+
96
+ const triggers = Array.from(this.querySelectorAll(':scope > fx-trigger'));
97
+ this.tabTriggers = triggers
98
+ .map(trigger => {
99
+ const toggle = trigger.querySelector(':scope > fx-toggle[case]');
100
+ if (!toggle) return null;
101
+ const targetCase = this.cases.find(c => c.id === toggle.getAttribute('case'));
102
+ if (!targetCase) return null;
103
+ const widget = (trigger.getWidget && trigger.getWidget()) || trigger.firstElementChild;
104
+ if (!widget) return null;
105
+ return { trigger, targetCase, widget };
106
+ })
107
+ .filter(Boolean);
108
+
109
+ if (this.tabTriggers.length === 0) return;
110
+
111
+ this.tabTriggers.forEach(({ trigger, targetCase, widget }) => {
112
+ trigger.setAttribute('slot', 'tab');
113
+ targetCase.id = targetCase.id || `fx-case-${Fore.createUUID()}`;
114
+ widget.id = widget.id || `fx-tab-${Fore.createUUID()}`;
115
+ widget.setAttribute('role', 'tab');
116
+ widget.setAttribute('aria-controls', targetCase.id);
117
+
118
+ targetCase.setAttribute('role', 'tabpanel');
119
+ targetCase.setAttribute('aria-labelledby', widget.id);
120
+ if (!targetCase.hasAttribute('tabindex')) {
121
+ targetCase.setAttribute('tabindex', '0');
122
+ }
123
+ });
124
+
125
+ this._syncTabSelection();
126
+ }
127
+
128
+ /**
129
+ * Reflects the current `selectedCase` onto the tab strip: `aria-selected` plus a roving
130
+ * tabindex (selected tab is the only one reachable by Tab; arrow keys move between the rest).
131
+ */
132
+ _syncTabSelection() {
133
+ this.tabTriggers.forEach(({ targetCase, widget }) => {
134
+ const selected = targetCase === this.selectedCase;
135
+ widget.setAttribute('aria-selected', String(selected));
136
+ widget.setAttribute('tabindex', selected ? '0' : '-1');
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Arrow-key navigation between tabs (Left/Right/Up/Down/Home/End), following the ARIA
142
+ * authoring practices "automatic activation" model - moving focus also selects the case.
143
+ */
144
+ async _handleTabKeydown(e) {
145
+ const { tabTriggers } = this;
146
+ if (tabTriggers.length === 0) return;
147
+
148
+ const idx = tabTriggers.findIndex(({ widget }) => widget === e.target);
149
+ if (idx === -1) return;
150
+
151
+ let newIdx;
152
+ switch (e.key) {
153
+ case 'ArrowRight':
154
+ case 'ArrowDown':
155
+ newIdx = (idx + 1) % tabTriggers.length;
156
+ break;
157
+ case 'ArrowLeft':
158
+ case 'ArrowUp':
159
+ newIdx = (idx - 1 + tabTriggers.length) % tabTriggers.length;
160
+ break;
161
+ case 'Home':
162
+ newIdx = 0;
163
+ break;
164
+ case 'End':
165
+ newIdx = tabTriggers.length - 1;
166
+ break;
167
+ default:
168
+ return;
169
+ }
170
+ e.preventDefault();
171
+ const { trigger, widget } = tabTriggers[newIdx];
172
+ await trigger.performActions();
173
+ widget.focus();
174
+ }
175
+
67
176
  _dispatchEvents() {
68
177
  if (this.selectedCase === this.formerCase) return;
69
178
  if (this.formerCase && this.formerCase !== this.selectedCase) {
@@ -150,6 +259,10 @@ class FxSwitch extends FxContainer {
150
259
  this._dispatchEvents();
151
260
  this.formerCase = this.selectedCase;
152
261
 
262
+ if (this.tabsMode) {
263
+ this._syncTabSelection();
264
+ }
265
+
153
266
  // Tell the owner form we might have new template expressions here
154
267
  this.getOwnerForm().scanForNewTemplateExpressionsNextRefresh();
155
268
  this.getOwnerForm().getModel().updateModel();
@@ -26,7 +26,7 @@ export class FxTrigger extends XfAbstractControl {
26
26
  if (!elements[0].getAttribute('tabindex')) {
27
27
  elements[0].setAttribute('tabindex', '0');
28
28
  }
29
- if (elements[0].nodeName !== 'BUTTON') {
29
+ if (elements[0].nodeName !== 'BUTTON' && !elements[0].getAttribute('role')) {
30
30
  elements[0].setAttribute('role', 'button');
31
31
  }
32
32
 
@@ -98,11 +98,16 @@ export class FxTrigger extends XfAbstractControl {
98
98
  repeatedItem.click();
99
99
  }
100
100
 
101
- // Update all child variables, but only once
102
- this.querySelectorAll('fx-var').forEach(variableElement => variableElement.refresh());
103
-
104
101
  for (let i = 0; i < this.children.length; i += 1) {
105
102
  const child = this.children[i];
103
+ // XForms 2.0: action variables are evaluated in sequence between sibling
104
+ // actions — a variable after an action sees that action's effects. Variables
105
+ // nested inside an fx-action child are evaluated by fx-action.perform().
106
+ if (child.localName === 'fx-var') {
107
+ child.refresh();
108
+ // eslint-disable-next-line no-continue
109
+ continue;
110
+ }
106
111
  if (typeof child.execute === 'function') {
107
112
  if (e) {
108
113
  // We are handling the event. Stop it from going further
@@ -39,6 +39,7 @@ export default class FxUpload extends XfAbstractControl {
39
39
  },
40
40
  };
41
41
  }
42
+
42
43
  connectedCallback() {
43
44
  this.updateEvent = 'change';
44
45
  this.accept = this.hasAttribute('accept') ? this.getAttribute('accept') : '';
@@ -68,7 +69,7 @@ export default class FxUpload extends XfAbstractControl {
68
69
  });
69
70
 
70
71
  // console.log('widget ', this.widget);
71
- let listenOn = this.widget; // default: usually listening on widget
72
+ const listenOn = this.widget; // default: usually listening on widget
72
73
 
73
74
  // ### convenience marker event
74
75
  if (this.debounceDelay) {
@@ -137,7 +138,7 @@ export default class FxUpload extends XfAbstractControl {
137
138
  const readMethod = isTextFile ? 'readAsText' : 'readAsDataURL';
138
139
 
139
140
  reader.onload = () => {
140
- const result = reader.result;
141
+ const { result } = reader;
141
142
  // If it's a binary file, return only the Base64 content without the MIME prefix
142
143
  if (!isTextFile) {
143
144
  const base64Content = result.split(',')[1]; // Remove MIME prefix
@@ -163,6 +164,10 @@ export default class FxUpload extends XfAbstractControl {
163
164
  * @param val the new value to be set
164
165
  */
165
166
  setValue(val) {
167
+ const model = this.getModel();
168
+ const undoManager = model?.getEffectiveUndoManager();
169
+ undoManager?.beginCapture();
170
+
166
171
  this.value = val;
167
172
  const modelitem = this.getModelItem();
168
173
 
@@ -189,6 +194,7 @@ export default class FxUpload extends XfAbstractControl {
189
194
 
190
195
  if (modelitem?.readonly) {
191
196
  console.warn('attempt to change readonly node', modelitem);
197
+ undoManager?.discard();
192
198
  return; // do nothing when modelItem is readonly
193
199
  }
194
200
 
@@ -200,7 +206,6 @@ export default class FxUpload extends XfAbstractControl {
200
206
  this.modelItem.boundControls.push(this);
201
207
  }
202
208
  */
203
- const model = this.getModel();
204
209
  Fore.dispatch(this, 'value-changed', {
205
210
  path: this.modelItem.path,
206
211
  value: this.modelItem.value,
@@ -208,8 +213,9 @@ export default class FxUpload extends XfAbstractControl {
208
213
  instanceId: this.modelItem.instanceId,
209
214
  foreId: this.getOwnerForm().id,
210
215
  });
211
- this.getModel().updateModel();
216
+ model.updateModel();
212
217
  this.getOwnerForm().refresh(true);
218
+ undoManager?.commit(modelitem?.node);
213
219
 
214
220
  // console.log('data', this.getOwnerForm().getModel().getDefaultInstanceData());
215
221
  }
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { Fore } from '../fore.js';
18
18
  import { evaluateXPath } from '../xpath-evaluation.js';
19
+ import { DependencyNotifyingDomFacade } from '../DependencyNotifyingDomFacade.js';
19
20
  import getInScopeContext from '../getInScopeContext.js';
20
21
  import { withDraggability } from '../withDraggability.js';
21
22
  import { UIElement } from './UIElement.js';
@@ -179,6 +180,7 @@ export class RepeatBase extends withDraggability(UIElement, false) {
179
180
  }
180
181
 
181
182
  disconnectedCallback() {
183
+ if (super.disconnectedCallback) super.disconnectedCallback();
182
184
  this.getOwnerForm().removeEventListener('deleted', this.handleDelete);
183
185
  this.getOwnerForm().removeEventListener('insert', this.handleInsert);
184
186
  }
@@ -217,6 +219,7 @@ export class RepeatBase extends withDraggability(UIElement, false) {
217
219
 
218
220
  this.getOwnerForm().addToBatchedNotifications(newRepeatItem);
219
221
  }
222
+
220
223
  /**
221
224
  * @abstract
222
225
  *
@@ -253,17 +256,19 @@ export class RepeatBase extends withDraggability(UIElement, false) {
253
256
  });
254
257
  }
255
258
 
256
- /*
257
- this.touchedPaths = new Set();
258
- const instance = XPathUtil.resolveInstance(this, this.ref);
259
- const depTrackDomfacade = new DependencyNotifyingDomFacade((node) => {
260
- this.touchedPaths.add(XPathUtil.getPath(node, instance));
261
- });
262
- const rawNodeset = evaluateXPath(this.ref, inscope, this, {}, {}, depTrackDomfacade );
263
- */
264
- const rawNodeset = evaluateXPath(this.ref, inscope, this);
265
-
266
- // console.log('Touched!', this.ref, [...this.touchedPaths].join(', '));
259
+ let touchedNodes = null;
260
+ let domFacade = null;
261
+ if (this._refNeedsDependencyTracking()) {
262
+ touchedNodes = new Set();
263
+ domFacade = new DependencyNotifyingDomFacade(node => touchedNodes.add(node));
264
+ }
265
+
266
+ const rawNodeset = evaluateXPath(this.ref, inscope, this, {}, {}, domFacade);
267
+
268
+ if (touchedNodes) {
269
+ this._trackRefDependencies(touchedNodes);
270
+ }
271
+
267
272
  if (rawNodeset.length === 1 && Array.isArray(rawNodeset[0])) {
268
273
  // This XPath likely returned an XPath array. Just collapse to that array
269
274
  this.nodeset = rawNodeset[0];
@@ -447,7 +452,10 @@ export class RepeatBase extends withDraggability(UIElement, false) {
447
452
  if (mi.path.startsWith(`${parentBaseInChildStyle}_`)) return;
448
453
 
449
454
  // Inject _op immediately after the parent base segment
450
- mi.path = `${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`;
455
+ this.getModel()._setModelItemPath(
456
+ mi,
457
+ `${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`,
458
+ );
451
459
  };
452
460
 
453
461
  if (parentNode.attachObserver) {
@@ -177,6 +177,10 @@ export const withDraggability = (superclass, isAlsoDraggable) =>
177
177
  this.replaceChildren(draggedItem);
178
178
  }
179
179
  */
180
+ // NOTE: this branch (fx-droptarget) reorders live DOM/UI elements directly, not
181
+ // instance data - there is nothing here for UndoManager's instance-data snapshots
182
+ // to capture, so it is intentionally not wrapped with undo capture (see the
183
+ // dataNode branch below for the case that IS undoable).
180
184
  event.preventDefault();
181
185
  this.getOwnerForm().getModel().updateModel();
182
186
  this.getOwnerForm().refresh(true);
@@ -187,6 +191,10 @@ export const withDraggability = (superclass, isAlsoDraggable) =>
187
191
  return;
188
192
  }
189
193
 
194
+ const model = this.getOwnerForm().getModel();
195
+ const undoManager = model.getEffectiveUndoManager();
196
+ undoManager.beginCapture();
197
+
190
198
  if (this.localName === 'fx-repeat') {
191
199
  // We are sure we'll handle this event!
192
200
  event.preventDefault();
@@ -217,7 +225,8 @@ export const withDraggability = (superclass, isAlsoDraggable) =>
217
225
  }
218
226
 
219
227
  // Note: full refresh needed since multiple model items may be affected.
220
- this.getOwnerForm().getModel().updateModel();
228
+ model.updateModel();
221
229
  this.getOwnerForm().refresh(true);
230
+ undoManager.commit(dataNode);
222
231
  }
223
232
  };
@@ -1360,7 +1360,7 @@ export function evaluateXPath(
1360
1360
  return fxEvaluateXPath(
1361
1361
  expr0,
1362
1362
  contextNode,
1363
- __jsonDomFacade,
1363
+ domFacade ? domFacade.withInner(__jsonDomFacade) : __jsonDomFacade,
1364
1364
  { ...variablesInScope, ...variables },
1365
1365
  fxEvaluateXPath.ALL_RESULTS_TYPE,
1366
1366
  {
@@ -1781,14 +1781,20 @@ export function evaluateXPathToString(xpath, contextNode, formElement, domFacade
1781
1781
  // JSON context, non-lookup => evaluate via JSON facade
1782
1782
  if (_isJsonNode(contextNode) && !_looksLikeLookupExpr(expr0)) {
1783
1783
  const variablesInScope = getVariablesInScope(formElement);
1784
- const res = fxEvaluateXPathToString(expr0, contextNode, __jsonDomFacade, variablesInScope, {
1785
- currentContext: { formElement },
1786
- functionNameResolver,
1787
- moduleImports: { xf: XFORMS_NAMESPACE_URI },
1788
- namespaceResolver: null,
1789
- language: Language.XPATH_3_1_LANGUAGE,
1790
- xmlSerializer: new XMLSerializer(),
1791
- });
1784
+ const res = fxEvaluateXPathToString(
1785
+ expr0,
1786
+ contextNode,
1787
+ domFacade ? domFacade.withInner(__jsonDomFacade) : __jsonDomFacade,
1788
+ variablesInScope,
1789
+ {
1790
+ currentContext: { formElement },
1791
+ functionNameResolver,
1792
+ moduleImports: { xf: XFORMS_NAMESPACE_URI },
1793
+ namespaceResolver: null,
1794
+ language: Language.XPATH_3_1_LANGUAGE,
1795
+ xmlSerializer: new XMLSerializer(),
1796
+ },
1797
+ );
1792
1798
  return stringify(res);
1793
1799
  }
1794
1800
 
@@ -1906,7 +1912,7 @@ export function evaluateXPathToStrings(xpath, contextNode, formElement, domFacad
1906
1912
  const res = fxEvaluateXPathToStrings(
1907
1913
  expr0,
1908
1914
  contextNode,
1909
- __jsonDomFacade,
1915
+ domFacade ? domFacade.withInner(__jsonDomFacade) : __jsonDomFacade,
1910
1916
  getVariablesInScope(formElement),
1911
1917
  {
1912
1918
  currentContext: { formElement },
@@ -1986,14 +1992,20 @@ export function evaluateXPathToNumber(xpath, contextNode, formElement, domFacade
1986
1992
 
1987
1993
  if (_isJsonNode(contextNode) && !_looksLikeLookupExpr(expr0)) {
1988
1994
  const variablesInScope = getVariablesInScope(formElement);
1989
- return fxEvaluateXPathToNumber(expr0, contextNode, __jsonDomFacade, variablesInScope, {
1990
- currentContext: { formElement },
1991
- functionNameResolver,
1992
- moduleImports: { xf: XFORMS_NAMESPACE_URI },
1993
- namespaceResolver: null,
1994
- language: Language.XPATH_3_1_LANGUAGE,
1995
- xmlSerializer: new XMLSerializer(),
1996
- });
1995
+ return fxEvaluateXPathToNumber(
1996
+ expr0,
1997
+ contextNode,
1998
+ domFacade ? domFacade.withInner(__jsonDomFacade) : __jsonDomFacade,
1999
+ variablesInScope,
2000
+ {
2001
+ currentContext: { formElement },
2002
+ functionNameResolver,
2003
+ moduleImports: { xf: XFORMS_NAMESPACE_URI },
2004
+ namespaceResolver: null,
2005
+ language: Language.XPATH_3_1_LANGUAGE,
2006
+ xmlSerializer: new XMLSerializer(),
2007
+ },
2008
+ );
1997
2009
  }
1998
2010
 
1999
2011
  if (_looksLikeLookupExpr(expr0)) {
package/src/xpath-path.js CHANGED
@@ -14,11 +14,130 @@ function shortenPath(path) {
14
14
  return tmp1.substring(tmp1.indexOf('/'), tmp.length);
15
15
  }
16
16
 
17
+ // --- fast native path computation -------------------------------------------------
18
+ //
19
+ // fontoxpath's `path()` function is correct but calling it once per node has real fixed
20
+ // overhead (compiling/interpreting the query, building a dynamic context, walking the
21
+ // domFacade) that dominates in practice over the tree walk itself. getPath()/getDocPath()
22
+ // are called once per node during bind-graph construction (fx-bind.js's _buildBindGraph())
23
+ // AND once per node inside any `iterate` action (fx-setattribute.js, fx-setvalue.js) - so a
24
+ // large sibling list (eg. an 800-row genericode codelist) previously meant 800+ separate
25
+ // fontoxpath `path()` evaluations, each re-scanning preceding siblings to compute the index
26
+ // -- measured at several seconds for UNTDID 1001 (802 Row elements) in
27
+ // demo/codelists/codelist-editor.html.
28
+ //
29
+ // nativeDocPath() below replicates the exact string format `path()` produces (post
30
+ // shortenPath) using plain DOM traversal - same asymptotic sibling-counting cost as
31
+ // fontoxpath's own implementation, but without the XPath-engine overhead per call. It
32
+ // returns `null` for anything it isn't confident matches fontoxpath's behavior exactly (eg.
33
+ // exotic node types); callers fall back to the original fontoxpath-based path in that case,
34
+ // so correctness never depends on this function handling every possible node shape.
35
+
36
+ /**
37
+ * Identity used to count preceding siblings the way fn:path() does: same element name
38
+ * *and* namespace, or same text()/comment()/processing-instruction(target) kind. Two
39
+ * same-localName elements in different namespaces are (correctly) not siblings of the
40
+ * same "kind" here, matching fontoxpath indexing same-namespace elements independently.
41
+ * @param {Node} node
42
+ * @returns {string|null}
43
+ */
44
+ function segmentKey(node) {
45
+ switch (node.nodeType) {
46
+ case Node.ELEMENT_NODE:
47
+ return `E|${node.namespaceURI || ''}|${node.localName || node.nodeName}`;
48
+ case Node.TEXT_NODE:
49
+ case Node.CDATA_SECTION_NODE:
50
+ return 'T';
51
+ case Node.COMMENT_NODE:
52
+ return 'C';
53
+ case Node.PROCESSING_INSTRUCTION_NODE:
54
+ return `P|${node.target}`;
55
+ default:
56
+ return null;
57
+ }
58
+ }
59
+
60
+ /**
61
+ * @param {Node} node
62
+ * @returns {string|null}
63
+ */
64
+ function segmentLabel(node) {
65
+ switch (node.nodeType) {
66
+ case Node.ELEMENT_NODE:
67
+ return node.localName || node.nodeName;
68
+ case Node.TEXT_NODE:
69
+ case Node.CDATA_SECTION_NODE:
70
+ return 'text()';
71
+ case Node.COMMENT_NODE:
72
+ return 'comment()';
73
+ case Node.PROCESSING_INSTRUCTION_NODE:
74
+ return `processing-instruction(${node.target})`;
75
+ default:
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * 1-based index among preceding siblings sharing the same `segmentKey`.
82
+ * @param {Node} node
83
+ * @param {string} key
84
+ * @returns {number}
85
+ */
86
+ function siblingIndex(node, key) {
87
+ let index = 1;
88
+ let sib = node.previousSibling;
89
+ while (sib) {
90
+ if (segmentKey(sib) === key) index += 1;
91
+ sib = sib.previousSibling;
92
+ }
93
+ return index;
94
+ }
95
+
96
+ /**
97
+ * @param {Node} node
98
+ * @returns {string|null} the fontoxpath-`path()`-equivalent doc path (eg. `/a[1]/b[2]`,
99
+ * always starting with `/`), or `null` if this node shape isn't one this function is
100
+ * confident about - the caller should fall back to the fontoxpath-based computation.
101
+ */
102
+ function nativeDocPath(node) {
103
+ if (!node || node.nodeType === undefined) return null;
104
+
105
+ if (node.nodeType === Node.ATTRIBUTE_NODE) {
106
+ const owner = node.ownerElement;
107
+ if (!owner) return null;
108
+ const ownerPath = nativeDocPath(owner);
109
+ if (ownerPath === null) return null;
110
+ return `${ownerPath}/@${node.localName || node.name}`;
111
+ }
112
+
113
+ if (node.nodeType === Node.DOCUMENT_NODE) return '/';
114
+
115
+ // Build the segment chain from `node` up to (and including) the outermost ancestor
116
+ // that isn't a document node, then drop that outermost segment - matching the "cut-off
117
+ // root node ref" behavior of the original fontoxpath-based shortenPath().
118
+ const chain = [];
119
+ let current = node;
120
+ while (current && current.nodeType !== Node.DOCUMENT_NODE) {
121
+ const key = segmentKey(current);
122
+ const label = segmentLabel(current);
123
+ if (key === null || label === null) return null;
124
+ chain.push(`${label}[${siblingIndex(current, key)}]`);
125
+ current = current.parentNode;
126
+ }
127
+ chain.reverse();
128
+
129
+ if (chain.length > 1) chain.shift();
130
+ return chain.length ? `/${chain.join('/')}` : '/';
131
+ }
132
+
17
133
  /**
18
134
  * @param {Node} node
19
135
  * @returns string
20
136
  */
21
137
  export function getDocPath(node) {
138
+ const native = nativeDocPath(node);
139
+ if (native !== null) return native;
140
+
22
141
  const path = fx.evaluateXPathToString('path()', node);
23
142
  // Path is like `$default/x[1]/y[1]`
24
143
  const shortened = shortenPath(path);
@@ -101,6 +220,9 @@ export function getPath(node, instanceId = 'default') {
101
220
  }
102
221
 
103
222
  function getXmlPath(node, instanceId) {
223
+ const native = nativeDocPath(node);
224
+ if (native !== null) return `$${instanceId}${native}`;
225
+
104
226
  const path = fx.evaluateXPathToString('path()', node);
105
227
  // Path is like `$default/x[1]/y[1]`
106
228
  const shortened = shortenPath(path);