@jinntec/fore 3.2.1 → 3.3.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/modelitem.js CHANGED
@@ -28,8 +28,10 @@ export class ModelItem {
28
28
  constructor(path, ref, nodeOrLens, bind, instance, fore) {
29
29
  this.path = path;
30
30
  this.ref = ref;
31
- this.readonly = ModelItem.READONLY_DEFAULT;
32
- this.relevant = ModelItem.RELEVANT_DEFAULT;
31
+ this._readonly = ModelItem.READONLY_DEFAULT;
32
+ this._relevant = ModelItem.RELEVANT_DEFAULT;
33
+ // undefined = not yet resolved; null = resolved, no ancestor ModelItem found
34
+ this._parentModelItem = undefined;
33
35
  this.required = ModelItem.REQUIRED_DEFAULT;
34
36
  this.constraint = ModelItem.CONSTRAINT_DEFAULT;
35
37
  this.type = ModelItem.TYPE_DEFAULT;
@@ -44,6 +46,7 @@ export class ModelItem {
44
46
  this.instanceId = instance;
45
47
  this.fore = fore;
46
48
  this.changed = false;
49
+ this.nativeValid = true;
47
50
 
48
51
  // console.log('[ModelItem] created:', this.path);
49
52
 
@@ -64,6 +67,112 @@ export class ModelItem {
64
67
  this.state = {}; // evaluated expression results
65
68
  }
66
69
 
70
+ /**
71
+ * `readonly` is inherited down the instance tree per XForms semantics: a node is
72
+ * readonly if its own bind says so, or if any ancestor node is readonly.
73
+ */
74
+ get readonly() {
75
+ return this._readonly || !!this.getParentModelItem()?.readonly;
76
+ }
77
+
78
+ set readonly(val) {
79
+ this._readonly = val;
80
+ }
81
+
82
+ /**
83
+ * `relevant` is inherited down the instance tree per XForms semantics: a node is
84
+ * relevant only if its own bind says so AND its parent is (effectively) relevant.
85
+ */
86
+ get relevant() {
87
+ if (!this._relevant) return false;
88
+ const parent = this.getParentModelItem();
89
+ return parent ? parent.relevant : true;
90
+ }
91
+
92
+ set relevant(val) {
93
+ this._relevant = val;
94
+ }
95
+
96
+ /**
97
+ * Resolves and caches the nearest ancestor ModelItem, walking past instance
98
+ * nodes that have no ModelItem of their own. Handles both XML DOM nodes
99
+ * (`node.parentNode`) and JSON lens nodes (`lens.parent`).
100
+ * @returns {ModelItem|null}
101
+ */
102
+ getParentModelItem() {
103
+ if (this._parentModelItem !== undefined) return this._parentModelItem;
104
+
105
+ // `bind` is only set for ModelItems created from an explicit <fx-bind ref="...">.
106
+ // Lazily-created ModelItems (e.g. controls without a dedicated bind - the exact
107
+ // case this inheritance walk needs to cover) have no `bind`, so fall back to
108
+ // resolving the owning FxModel through the fx-fore element instead.
109
+ const model = this.bind?.model || this.fore?.getModel?.();
110
+ // `Attr.parentNode` is always null per the DOM spec - attribute nodes only
111
+ // expose their owning element via `ownerElement`. Without this, readonly/
112
+ // relevant inheritance silently breaks for every attribute-bound ModelItem.
113
+ let current;
114
+ if (this.lens) {
115
+ current = this.lens.parent;
116
+ } else if (this.node?.nodeType === Node.ATTRIBUTE_NODE) {
117
+ current = this.node.ownerElement;
118
+ } else {
119
+ current = this.node?.parentNode;
120
+ }
121
+
122
+ while (current) {
123
+ if (
124
+ current.nodeType === Node.DOCUMENT_NODE ||
125
+ current.nodeType === Node.DOCUMENT_FRAGMENT_NODE
126
+ ) {
127
+ break;
128
+ }
129
+
130
+ const mi = model?.getModelItem(current);
131
+ if (mi) {
132
+ this._parentModelItem = mi;
133
+ return mi;
134
+ }
135
+
136
+ current = current.__jsonlens__ === true ? current.parent : current.parentNode;
137
+ }
138
+
139
+ this._parentModelItem = null;
140
+ return null;
141
+ }
142
+
143
+ getDebugInfo() {
144
+ return {
145
+ path: this.path,
146
+ ref: this.ref,
147
+ instanceId: this.instanceId,
148
+ type: this.type,
149
+
150
+ value: this.value,
151
+
152
+ facets: {
153
+ readonly: this.readonly,
154
+ relevant: this.relevant,
155
+ required: this.required,
156
+ constraint: this.constraint,
157
+ changed: this.changed,
158
+ },
159
+
160
+ backing: this.lens
161
+ ? 'json-lens'
162
+ : this.node
163
+ ? 'xml-node'
164
+ : 'unknown',
165
+
166
+ observerCount: this.observers?.size || 0,
167
+ boundControlCount: this.boundControls?.length || 0,
168
+ dependencyCount: this.dependencies?.size || 0,
169
+ alertCount: this.alerts?.length || 0,
170
+
171
+ stateExpressions: this.stateExpressions,
172
+ state: this.state,
173
+ };
174
+ }
175
+
67
176
  get value() {
68
177
  if (this.lens) return this.lens.get();
69
178
  if (!this.node) return null;
@@ -278,4 +387,5 @@ export class ModelItem {
278
387
 
279
388
  return [];
280
389
  }
390
+
281
391
  }
@@ -320,13 +320,13 @@ export default class AbstractControl extends UIElement {
320
320
 
321
321
  handleReadonly() {
322
322
  // console.log('mip readonly', this.modelItem.isReadonly);
323
- if (this.isReadonly() !== this.modelItem.readonly) {
324
- if (this.modelItem.readonly) {
323
+ const effectiveReadonly = this.modelItem.readonly || this.isHostReadonly();
324
+ if (this.isReadonly() !== effectiveReadonly) {
325
+ if (effectiveReadonly) {
325
326
  this.widget.setAttribute('readonly', '');
326
327
  this.setAttribute('readonly', '');
327
328
  this._dispatchEvent('readonly');
328
- }
329
- if (!this.modelItem.readonly) {
329
+ } else {
330
330
  this.widget.removeAttribute('readonly');
331
331
  this.removeAttribute('readonly');
332
332
  this._dispatchEvent('readwrite');
@@ -334,6 +334,17 @@ export default class AbstractControl extends UIElement {
334
334
  }
335
335
  }
336
336
 
337
+ /**
338
+ * A control embedded via `src` (a whole nested `fx-fore` used as widget) has its
339
+ * own independent model, so it cannot see the readonly state of the outer
340
+ * ModelItem it represents. When the outer control becomes readonly, it marks its
341
+ * widget (the nested `fx-fore`) with a `readonly` attribute; nested controls pick
342
+ * that up here by checking their nearest enclosing `fx-fore`.
343
+ */
344
+ isHostReadonly() {
345
+ return !!this.closest('fx-fore')?.hasAttribute('readonly');
346
+ }
347
+
337
348
  // todo - review alert handling altogether. There could be potentially multiple ones in model
338
349
  // TODO: both required and handleValid set valid attrs and aria attrs. Duplicate code
339
350
  handleValid() {
@@ -343,7 +354,8 @@ export default class AbstractControl extends UIElement {
343
354
  const hasValue = this.modelItem.value !== '';
344
355
  const isRequired = this.modelItem.required;
345
356
  const isValidAccordingToRequired = isRequired ? hasValue : true;
346
- const isValidNow = this.modelItem.constraint && isValidAccordingToRequired;
357
+ const nativeValid = this.modelItem.nativeValid !== false;
358
+ const isValidNow = this.modelItem.constraint && isValidAccordingToRequired && nativeValid;
347
359
 
348
360
  if (this.isValid() !== isValidNow) {
349
361
  if (isValidNow) {
@@ -1,3 +1,4 @@
1
+ import { createTypedValueFactory, domFacade as fontoDomFacade } from 'fontoxpath';
1
2
  import XfAbstractControl from './abstract-control.js';
2
3
  import {
3
4
  evaluateXPath,
@@ -10,8 +11,10 @@ import { debounce } from '../events.js';
10
11
  import { FxModel } from '../fx-model.js';
11
12
  import { DependencyNotifyingDomFacade } from '../DependencyNotifyingDomFacade';
12
13
  import { extractPredicateDependencies } from '../extract-predicate-deps.js';
14
+ import '../fx-instance.js';
13
15
 
14
16
  const WIDGETCLASS = 'widget';
17
+ const typedValueFactory = createTypedValueFactory('item()*');
15
18
 
16
19
  /**
17
20
  * `fx-control`
@@ -542,6 +545,11 @@ export default class FxControl extends XfAbstractControl {
542
545
  dummy.replaceWith(imported);
543
546
  }
544
547
 
548
+ // `this.widget` was cached as the placeholder `dummy` input on init(); that
549
+ // node is now detached, so readonly/required handling would silently target
550
+ // a removed element. Point it at the embedded fx-fore, which now acts as the widget.
551
+ this.widget = imported;
552
+
545
553
  if (!theFore) {
546
554
  Fore.dispatch('error', {
547
555
  detail: {
@@ -568,12 +576,13 @@ export default class FxControl extends XfAbstractControl {
568
576
  try {
569
577
  this._isRefreshing = true;
570
578
  // console.log('🔄 fx-control refresh', this);
571
- super.refresh(force);
579
+ await super.refresh(force);
572
580
  // console.log('refresh template', this.template);
573
581
  // const {widget} = this;
574
582
 
575
583
  // ### if we find a ref on control we have a 'select' control of some kind
576
584
  const widget = this.getWidget();
585
+ await this._loadDataSrc(widget);
577
586
  this._handleBoundWidget(widget, force);
578
587
  this._handleDataAttributeBinding();
579
588
  } finally {
@@ -582,6 +591,94 @@ export default class FxControl extends XfAbstractControl {
582
591
  await Fore.refreshChildren(this, force);
583
592
  }
584
593
 
594
+ /**
595
+ * Finds (or lazily creates) the anonymous `<fx-instance>` backing a
596
+ * `data-src` lookup document for the given URL, deduped per-model by URL.
597
+ *
598
+ * The created instance has no `id`, so it stays invisible to the global
599
+ * `$default`/`$<id>` variable mechanism. It is appended as the last child
600
+ * of the model, so it is never mistaken for the first/default instance
601
+ * (which assumes the model already declares its own default instance).
602
+ *
603
+ * @param {string} url
604
+ * @param {string|null} type
605
+ * @returns {import('../fx-instance.js').FxInstance}
606
+ * @private
607
+ */
608
+ _ensureDataSrcInstance(url, type) {
609
+ const model = this.getModel();
610
+ let instEl = Array.from(model.children).find(
611
+ el => el.localName === 'fx-instance' && el.getAttribute('data-src') === url,
612
+ );
613
+
614
+ if (!instEl) {
615
+ instEl = document.createElement('fx-instance');
616
+ instEl.setAttribute('data-src', url);
617
+ instEl.setAttribute('src', url);
618
+ if (type) {
619
+ instEl.setAttribute('type', type);
620
+ }
621
+ model.appendChild(instEl);
622
+ instEl._loadPromise = instEl.init();
623
+ }
624
+ return instEl;
625
+ }
626
+
627
+ /**
628
+ * Implements the `data-src` lookup-list shortcut: if the bound widget
629
+ * declares `data-src="<url>"`, lazily loads that document via an anonymous
630
+ * `<fx-instance>` and binds its root element as a control-local XPath
631
+ * variable (`$src` by default, or `$<data-id>`), available to this
632
+ * control's own `ref` and template expressions.
633
+ *
634
+ * @param {HTMLElement} widget
635
+ * @private
636
+ */
637
+ async _loadDataSrc(widget) {
638
+ const url = widget.getAttribute && widget.getAttribute('data-src');
639
+ if (!url) return;
640
+
641
+ const varName = widget.getAttribute('data-id') || 'src';
642
+
643
+ if (this._dataSrcUrl === url) return;
644
+ this._dataSrcUrl = url;
645
+
646
+ // ### bind to an empty sequence until loaded so `ref` evaluation doesn't
647
+ // throw on the not-yet-bound variable; `_handleBoundWidget` bails out
648
+ // gracefully on an empty nodeset.
649
+ this.inScopeVariables.set(varName, typedValueFactory([], fontoDomFacade));
650
+
651
+ const instEl = this._ensureDataSrcInstance(url, widget.getAttribute('data-type'));
652
+
653
+ try {
654
+ await (instEl._loadPromise ?? Promise.resolve());
655
+
656
+ // ### derive the xpath default namespace for `$src`/`$<data-id>` once per
657
+ // anonymous instance: an explicit `data-xpath-ns` override wins, otherwise
658
+ // fall back to the namespace the loaded document declares on its root element.
659
+ if (!instEl.hasAttribute('xpath-default-namespace')) {
660
+ const override = widget.getAttribute('data-xpath-ns');
661
+ const root = instEl.getDefaultContext();
662
+ const ns = override || (root && root.namespaceURI);
663
+ if (ns) {
664
+ instEl.setAttribute('xpath-default-namespace', ns);
665
+ }
666
+ }
667
+
668
+ this.inScopeVariables.set(
669
+ varName,
670
+ typedValueFactory([instEl.getDefaultContext()], fontoDomFacade),
671
+ );
672
+ this.refresh(true);
673
+ } catch (error) {
674
+ Fore.dispatch(this, 'error', {
675
+ origin: this,
676
+ message: `control couldn't load data-src '${url}'`,
677
+ level: 'Error',
678
+ });
679
+ }
680
+ }
681
+
585
682
  /**
586
683
  * handle non-Fore elements like 'select' and 'datalist' which have a 'data-ref' attribute
587
684
  * @private
@@ -56,8 +56,8 @@ export class FxItems extends FxControl {
56
56
  });
57
57
 
58
58
  this.addEventListener('click', e => {
59
- e.preventDefault; // keep as-is (do not change behavior here)
60
- e.stopPropagation();
59
+ // e.preventDefault(); // keep as-is (do not change behavior here)
60
+ // e.stopPropagation();
61
61
  const items = this.querySelectorAll('[value]');
62
62
 
63
63
  let val = '';
@@ -86,7 +86,7 @@ export class FxItems extends FxControl {
86
86
  }
87
87
 
88
88
  async refresh(force = false) {
89
- super.refresh(force);
89
+ await super.refresh(force);
90
90
  // console.log('fx-items.refresh() called');
91
91
  }
92
92
 
@@ -741,6 +741,41 @@ function findInstanceReferences(xpathQuery) {
741
741
  return instanceReferences;
742
742
  }
743
743
 
744
+ /**
745
+ * Finds the anonymous `<fx-instance data-src="...">` (created by
746
+ * `fx-control._ensureDataSrcInstance`) backing a `$src`/`$<data-id>` variable
747
+ * referenced in `xpathQuery`, if any. Used so the namespace resolver can pick up
748
+ * that instance's `xpath-default-namespace` instead of inheriting it from the
749
+ * form's default instance.
750
+ */
751
+ function findDataSrcInstance(xpathQuery, formElement) {
752
+ if (!formElement || typeof formElement.querySelectorAll !== 'function') return null;
753
+
754
+ const varNames = [...xpathQuery.matchAll(/\$([A-Za-z_][\w.-]*)/g)].map(m => m[1]);
755
+ if (varNames.length === 0) return null;
756
+
757
+ const widgets = Array.from(formElement.querySelectorAll('[data-src]')).filter(
758
+ widget => widget.closest('fx-control') === formElement,
759
+ );
760
+ if (widgets.length === 0) return null;
761
+
762
+ const model = typeof formElement.getModel === 'function' ? formElement.getModel() : null;
763
+ if (!model) return null;
764
+
765
+ for (const varName of varNames) {
766
+ const widget = widgets.find(w => (w.getAttribute('data-id') || 'src') === varName);
767
+ if (!widget) continue;
768
+
769
+ const url = widget.getAttribute('data-src');
770
+ const instance = Array.from(model.children).find(
771
+ el => el.localName === 'fx-instance' && el.getAttribute('data-src') === url,
772
+ );
773
+ if (instance) return instance;
774
+ }
775
+
776
+ return null;
777
+ }
778
+
744
779
  function getCachedNamespaceResolver(xpath, node) {
745
780
  if (!createdNamespaceResolversByXPathQueryAndNode.has(xpath)) return null;
746
781
  return createdNamespaceResolversByXPathQueryAndNode.get(xpath).get(node) || null;
@@ -776,6 +811,14 @@ export function createNamespaceResolver(xpathQuery, formElement) {
776
811
  };
777
812
 
778
813
  if (instanceReferences.length === 0) {
814
+ const dataSrcInstance = findDataSrcInstance(xpathQuery, formElement);
815
+ if (dataSrcInstance && dataSrcInstance.hasAttribute('xpath-default-namespace')) {
816
+ const xpathDefaultNamespace = dataSrcInstance.getAttribute('xpath-default-namespace');
817
+ const resolveNamespacePrefix = prefix => (!prefix ? xpathDefaultNamespace : undefined);
818
+ setCachedNamespaceResolver(xpathQuery, formElement, resolveNamespacePrefix);
819
+ return resolveNamespacePrefix;
820
+ }
821
+
779
822
  const ancestorComponent = closestRefExcludingSelf(formElement);
780
823
 
781
824
  if (ancestorComponent && ancestorComponent !== formElement) {