@jinntec/fore 3.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jinntec/fore",
3
- "version": "3.3.0",
3
+ "version": "3.3.1",
4
4
  "description": "Fore - declarative user interfaces in plain HTML",
5
5
  "module": "./index.js",
6
6
  "publishConfig": {
@@ -94,13 +94,14 @@
94
94
  "test:bs": "karma start karma.bs.config.js --coverage",
95
95
  "start:build": "cd dist && es-dev-server --open",
96
96
  "build": "rimraf dist && vite build --config vite.build.config.js && vite build --config vite.build-dev.config.js",
97
- "build:rollup": "rimraf dist && rollup -c rollup.config.js",
98
97
  "start": "vite --open --port 8090 --host",
99
98
  "preversion": "npm run test",
100
99
  "prepare": "npm run build",
101
100
  "install-demos": "npm i && cd demo && npm i",
102
101
  "start-cypress": "npm run start",
103
- "websocket-server": "node websocket-server.js"
102
+ "websocket-server": "node websocket-server.js",
103
+ "release": "bash scripts/release.sh",
104
+ "release:demo": "bash scripts/update-demo.sh"
104
105
  },
105
106
  "keywords": [
106
107
  "Fore",
@@ -100,6 +100,50 @@
100
100
  background: inherit;
101
101
  }
102
102
 
103
+ /*
104
+ * Default visual treatment for readonly controls (own or inherited via
105
+ * <fx-bind readonly="...">). Individual apps can override.
106
+ */
107
+ fx-control[readonly] .widget,
108
+ fx-control[readonly] input,
109
+ fx-control[readonly] textarea,
110
+ fx-control[readonly] select {
111
+ background: var(--paper-grey-200);
112
+ color: var(--paper-grey-600);
113
+ cursor: not-allowed;
114
+ }
115
+
116
+ /*
117
+ * The native `readonly` attribute is a no-op on checkboxes/radios per the HTML
118
+ * spec - only `disabled` blocks them, but that would also mute their styling
119
+ * and remove them from focus order. Fore already rejects the resulting
120
+ * value-changed on the model side (see fx-control#setValue), but without this
121
+ * the box still visibly toggles and snaps back, reading as "still clickable".
122
+ * Blocking pointer interaction avoids that flicker entirely.
123
+ */
124
+ fx-control[readonly] input[type="checkbox"],
125
+ fx-control[readonly] input[type="radio"] {
126
+ pointer-events: none;
127
+ }
128
+
129
+ /*
130
+ * Clicking a <label for="..."> forwards a native activation to its checkbox/
131
+ * radio that bypasses the input's own `pointer-events: none` entirely - the
132
+ * browser toggles the control directly, it's not a hit-test on the input. The
133
+ * label itself must be blocked too, or the box still visibly flips on click.
134
+ */
135
+ fx-control[readonly]:has(input[type="checkbox"]) label,
136
+ fx-control[readonly]:has(input[type="radio"]) label {
137
+ pointer-events: none;
138
+ cursor: not-allowed;
139
+ }
140
+
141
+ fx-trigger[readonly] .widget,
142
+ fx-trigger[readonly] button {
143
+ cursor: not-allowed;
144
+ opacity: 0.6;
145
+ }
146
+
103
147
  .error {
104
148
  background: var(--paper-red-500);
105
149
  display: flex;
@@ -7,6 +7,23 @@ const LOCAL_FUNCTIONS_NS = 'http://www.w3.org/2005/xquery-local-functions';
7
7
  // Keyed by resolved URL + prefix + mode.
8
8
  const _functionLibLoadCache = new Map();
9
9
 
10
+ // registerFunction() enforces "first wins" for conflicting names, but library sources
11
+ // (module imports, fetches) resolve asynchronously in whatever order the network/parser
12
+ // finishes them in. To make "first" mean "first in document order" (deterministic, and
13
+ // independent of load timing) rather than "first promise to resolve" (racy), every real
14
+ // loader reserves its turn synchronously in connectedCallback -- before any await -- and
15
+ // only applies its registrations once all earlier-reserved loaders have applied theirs.
16
+ let _registrationOrderChain = Promise.resolve();
17
+
18
+ function _reserveRegistrationTurn() {
19
+ const waitForTurn = _registrationOrderChain;
20
+ let releaseTurn;
21
+ _registrationOrderChain = new Promise(resolve => {
22
+ releaseTurn = resolve;
23
+ });
24
+ return { waitForTurn, releaseTurn };
25
+ }
26
+
10
27
  function looksLikeModuleSrc(src) {
11
28
  return /\.m?js($|\?)/i.test(src);
12
29
  }
@@ -87,11 +104,19 @@ export class FxFunctionlib extends ForeElementMixin {
87
104
  return;
88
105
  }
89
106
 
107
+ // Reserve our place in the registration order now, synchronously, so it reflects
108
+ // document order rather than whichever loader's fetch/import happens to finish first.
109
+ const { waitForTurn, releaseTurn } = _reserveRegistrationTurn();
110
+
90
111
  const loadPromise = (async () => {
91
- if (isModule) {
92
- await this._loadModuleLibrary(resolvedUrl, src, prefix);
93
- } else {
94
- await this._loadHtmlLibrary(resolvedUrl, src, prefix);
112
+ try {
113
+ if (isModule) {
114
+ await this._loadModuleLibrary(resolvedUrl, src, prefix, waitForTurn);
115
+ } else {
116
+ await this._loadHtmlLibrary(resolvedUrl, src, prefix, waitForTurn);
117
+ }
118
+ } finally {
119
+ releaseTurn();
95
120
  }
96
121
  })();
97
122
 
@@ -130,11 +155,13 @@ export class FxFunctionlib extends ForeElementMixin {
130
155
  registerFunction({ ...functionObject, signature: sig }, this);
131
156
  }
132
157
 
133
- async _loadModuleLibrary(resolvedUrl, src, prefix) {
158
+ async _loadModuleLibrary(resolvedUrl, src, prefix, waitForTurn) {
134
159
  const mod = await import(/* @vite-ignore */ resolvedUrl);
135
160
 
136
161
  const items = normalizeModuleExportToList(mod, src);
137
162
 
163
+ await waitForTurn;
164
+
138
165
  for (const item of items) {
139
166
  if (typeof item === 'function') {
140
167
  const { signature } = item;
@@ -154,7 +181,7 @@ export class FxFunctionlib extends ForeElementMixin {
154
181
  }
155
182
  }
156
183
 
157
- async _loadHtmlLibrary(resolvedUrl, src, prefix) {
184
+ async _loadHtmlLibrary(resolvedUrl, src, prefix, waitForTurn) {
158
185
  const result = await fetch(resolvedUrl);
159
186
  if (!result.ok) {
160
187
  console.error(`Loading function library at ${src} failed.`);
@@ -165,6 +192,9 @@ export class FxFunctionlib extends ForeElementMixin {
165
192
  const document = new DOMParser().parseFromString(body, 'text/html');
166
193
 
167
194
  const functions = Array.from(document.querySelectorAll('fx-function'));
195
+
196
+ await waitForTurn;
197
+
168
198
  for (const func of functions) {
169
199
  const functionObject = {
170
200
  type: func.getAttribute('type'),
package/src/fx-model.js CHANGED
@@ -406,6 +406,7 @@ export class FxModel extends HTMLElement {
406
406
  // Tabula rasa for computed facets; keep identity (boundControls/observers)
407
407
  mi.readonly = ModelItem.READONLY_DEFAULT;
408
408
  mi.relevant = ModelItem.RELEVANT_DEFAULT;
409
+ mi._parentModelItem = undefined;
409
410
  mi.required = ModelItem.REQUIRED_DEFAULT;
410
411
  mi.constraint = ModelItem.CONSTRAINT_DEFAULT;
411
412
  mi.type = ModelItem.TYPE_DEFAULT;
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;
@@ -65,6 +67,79 @@ export class ModelItem {
65
67
  this.state = {}; // evaluated expression results
66
68
  }
67
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
+
68
143
  getDebugInfo() {
69
144
  return {
70
145
  path: this.path,
@@ -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() {
@@ -545,6 +545,11 @@ export default class FxControl extends XfAbstractControl {
545
545
  dummy.replaceWith(imported);
546
546
  }
547
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
+
548
553
  if (!theFore) {
549
554
  Fore.dispatch('error', {
550
555
  detail: {