@jinntec/fore 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -320,7 +320,13 @@ export class FxInstance extends HTMLElement {
320
320
  const instanceData = new DOMParser().parseFromString(this.innerHTML, 'application/xml');
321
321
  this._setInitialData(instanceData);
322
322
  } else if (this.type === 'json') {
323
- this._setInitialData(JSON.parse(this.textContent));
323
+ // Use innerHTML (not textContent) so HTML tags the browser parser consumed as
324
+ // child elements (e.g. <blockquote> in a string value) are serialized back to text.
325
+ // Then escape literal control characters that JSON.parse rejects inside strings.
326
+ const sanitized = this.innerHTML.replace(/("(?:[^"\\]|\\.)*")/gs, match =>
327
+ match.replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t'),
328
+ );
329
+ this._setInitialData(JSON.parse(sanitized));
324
330
  } else if (this.type === 'html') {
325
331
  this._setInitialData(this.firstElementChild.children);
326
332
  } else if (this.type === 'text') {
@@ -0,0 +1,268 @@
1
+ // fx-speech.js — Fore-compatible voice input component with focus alignment, restart, repeat/back commands, and visual listening indicator
2
+ class FxSpeech extends HTMLElement {
3
+ constructor() {
4
+ super();
5
+ this.attachShadow({ mode: 'open' });
6
+ this.mode = this.getAttribute('mode') || 'guided';
7
+ this.currentIndex = 0;
8
+ this.controls = [];
9
+ this.recognition = null;
10
+ this.lastInputCaptured = false;
11
+ this.awaitingInput = false;
12
+ this.waitingToAdvance = false;
13
+ }
14
+
15
+ connectedCallback() {
16
+ this.shadowRoot.innerHTML = `
17
+ <style>
18
+ button { margin: 0.5em; padding: 0.5em 1em; font-size: 1em; }
19
+ #status { display: inline-block; margin-left: 1em; font-weight: bold; color: green; visibility: hidden; }
20
+ #status.listening { visibility: visible; animation: pulse 1s infinite; }
21
+ @keyframes pulse {
22
+ 0% { opacity: 0.3; }
23
+ 50% { opacity: 1; }
24
+ 100% { opacity: 0.3; }
25
+ }
26
+ </style>
27
+ <button id="start">🎤 Start Speech Input</button>
28
+ <button id="retry" style="display:none;">🔁 Continue</button>
29
+ <span id="status">🎧 Listening…</span>
30
+ `;
31
+
32
+ this.controls = Array.from(document.querySelectorAll('fx-control'));
33
+ this.initSpeech();
34
+
35
+ this.shadowRoot.getElementById('start').addEventListener('click', () => {
36
+ this.startInteraction();
37
+ });
38
+
39
+ this.shadowRoot.getElementById('retry').addEventListener('click', () => {
40
+ this.startGuided();
41
+ });
42
+
43
+ document.addEventListener('focusin', e => {
44
+ const targetControl = e.target.closest('fx-control');
45
+ if (targetControl) {
46
+ const index = this.controls.indexOf(targetControl);
47
+ if (index !== -1) {
48
+ this.currentIndex = index;
49
+ }
50
+ }
51
+ });
52
+ }
53
+
54
+ initSpeech() {
55
+ const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
56
+ if (!SpeechRecognition) {
57
+ alert('Web Speech API not supported in this browser.');
58
+ return;
59
+ }
60
+ this.recognition = new SpeechRecognition();
61
+ this.recognition.lang = 'en-US';
62
+ this.recognition.interimResults = false;
63
+ this.recognition.continuous = false;
64
+
65
+ this.recognition.onresult = event => {
66
+ this.lastSpoken = null;
67
+ const spoken = event.results[0][0].transcript.trim();
68
+ console.log('Recognized:', spoken);
69
+ this.lastInputCaptured = true;
70
+ this.awaitingInput = false;
71
+ this.toggleListening(false);
72
+
73
+ if (this.mode === 'guided') {
74
+ this.lastSpoken = spoken.toLowerCase();
75
+ this.applyGuidedInput(this.lastSpoken);
76
+ } else {
77
+ this.handleCommandInput(spoken.toLowerCase());
78
+ }
79
+ };
80
+
81
+ this.recognition.onerror = e => {
82
+ console.warn('Speech error:', e.error);
83
+ this.awaitingInput = false;
84
+ this.toggleListening(false);
85
+ if (this.mode === 'guided' && !this.waitingToAdvance) this.retryGuided();
86
+ };
87
+
88
+ this.recognition.onend = () => {
89
+ this.recognitionActive = false;
90
+ console.log('Recognition ended');
91
+ this.toggleListening(false);
92
+ if (this.mode === 'guided') {
93
+ if (this.lastInputCaptured && !['next', 'back'].includes(this.lastSpoken)) {
94
+ this.advanceToNextField();
95
+ } else if (this.awaitingInput && !this.waitingToAdvance) {
96
+ this.retryGuided();
97
+ }
98
+ }
99
+ };
100
+
101
+ this.recognition.onstart = () => {
102
+ this.recognitionActive = true;
103
+ console.log('Recognition started');
104
+ this.toggleListening(true);
105
+ };
106
+ }
107
+
108
+ toggleListening(state) {
109
+ const status = this.shadowRoot.getElementById('status');
110
+ if (state) {
111
+ status.classList.add('listening');
112
+ } else {
113
+ status.classList.remove('listening');
114
+ }
115
+ }
116
+
117
+ speak(text, callback) {
118
+ const utterance = new SpeechSynthesisUtterance(text);
119
+ utterance.onend = async () => {
120
+ await this.waitForSpeechSynthesisToEnd();
121
+ if (callback) callback();
122
+ };
123
+ speechSynthesis.speak(utterance);
124
+ }
125
+
126
+ async waitForSpeechSynthesisToEnd() {
127
+ while (speechSynthesis.speaking) {
128
+ await new Promise(resolve => setTimeout(resolve, 50));
129
+ }
130
+ }
131
+
132
+ getLabelText(control) {
133
+ return (
134
+ control.getAttribute('aria-label') ||
135
+ control.querySelector('label')?.textContent?.trim() ||
136
+ 'unknown field'
137
+ );
138
+ }
139
+
140
+ getInputElement(control) {
141
+ return control.querySelector('input, textarea, select');
142
+ }
143
+
144
+ startInteraction() {
145
+ this.shadowRoot.getElementById('retry').style.display = 'none';
146
+ if (this.mode === 'guided') {
147
+ this.startGuided();
148
+ } else {
149
+ this.recognition.start();
150
+ }
151
+ }
152
+
153
+ startGuided() {
154
+ this.shadowRoot.getElementById('retry').style.display = 'none';
155
+ if (this.currentIndex >= this.controls.length) {
156
+ this.speak('All fields completed.', () => {
157
+ this.currentIndex = 0;
158
+ this.shadowRoot.getElementById('start').textContent = '🔁 Restart Speech Input';
159
+ this.shadowRoot.getElementById('retry').style.display = 'inline-block';
160
+ });
161
+ return;
162
+ }
163
+ this.lastInputCaptured = false;
164
+ this.awaitingInput = true;
165
+ this.waitingToAdvance = false;
166
+ const control = this.controls[this.currentIndex];
167
+ const label = this.getLabelText(control);
168
+ const input = this.getInputElement(control);
169
+ input?.focus();
170
+
171
+ console.log('Prompting for field:', label);
172
+ this.speak(`Please say value for ${label}`, () => {
173
+ console.log('Starting recognition for:', label);
174
+ if (!this.recognitionActive) this.recognition.start();
175
+ });
176
+ }
177
+
178
+ retryGuided() {
179
+ this.shadowRoot.getElementById('retry').style.display = 'inline-block';
180
+ this.awaitingInput = false;
181
+ this.speak('Please try again or tap continue.');
182
+ }
183
+
184
+ applyGuidedInput(spoken) {
185
+ if (spoken === 'clear') {
186
+ const control = this.controls[this.currentIndex];
187
+ const input = this.getInputElement(control);
188
+ if (input) {
189
+ input.value = '';
190
+ input.dispatchEvent(new Event('input', { bubbles: true }));
191
+ this.speak('Cleared');
192
+ }
193
+ return;
194
+ }
195
+ if (spoken === 'next') {
196
+ this.advanceToNextField();
197
+ return;
198
+ }
199
+ if (spoken === 'repeat') {
200
+ this.startGuided();
201
+ return;
202
+ }
203
+ if (spoken === 'back') {
204
+ this.currentIndex = Math.max(0, this.currentIndex - 1);
205
+ this.startGuided();
206
+ return;
207
+ }
208
+ const control = this.controls[this.currentIndex];
209
+ const input = this.getInputElement(control);
210
+ if (input) {
211
+ input.value = spoken;
212
+ input.dispatchEvent(new Event('input', { bubbles: true }));
213
+ }
214
+ }
215
+
216
+ advanceToNextField() {
217
+ this.waitingToAdvance = true;
218
+ setTimeout(() => {
219
+ this.currentIndex++;
220
+ this.startGuided();
221
+ }, 1000);
222
+ }
223
+
224
+ handleCommandInput(spoken) {
225
+ if (spoken.startsWith('skip to')) {
226
+ const label = spoken.replace('skip to', '').trim();
227
+ const target = this.controls.find(ctrl => this.getLabelText(ctrl).toLowerCase() === label);
228
+ if (target) {
229
+ this.currentIndex = this.controls.indexOf(target);
230
+ this.getInputElement(target)?.focus();
231
+ this.speak(`Skipping to ${label}`);
232
+ } else {
233
+ this.speak(`Label "${label}" not found.`);
234
+ }
235
+ return;
236
+ }
237
+
238
+ if (spoken === 'next') {
239
+ this.currentIndex++;
240
+ return;
241
+ }
242
+ if (spoken === 'repeat') {
243
+ this.startGuided();
244
+ return;
245
+ }
246
+ if (spoken === 'back') {
247
+ this.currentIndex = Math.max(0, this.currentIndex - 1);
248
+ this.startGuided();
249
+ return;
250
+ }
251
+
252
+ const [label, ...rest] = spoken.split(' ');
253
+ const value = rest.join(' ');
254
+ const target = this.controls.find(ctrl => this.getLabelText(ctrl).toLowerCase() === label);
255
+ if (target) {
256
+ const input = this.getInputElement(target);
257
+ if (input) {
258
+ input.value = value;
259
+ input.dispatchEvent(new Event('input', { bubbles: true }));
260
+ input.focus();
261
+ }
262
+ } else {
263
+ this.speak(`Label "${label}" not found.`);
264
+ }
265
+ }
266
+ }
267
+
268
+ customElements.define('fx-speech', FxSpeech);
@@ -335,17 +335,25 @@ export default class AbstractControl extends UIElement {
335
335
  }
336
336
 
337
337
  // todo - review alert handling altogether. There could be potentially multiple ones in model
338
+ // TODO: both required and handleValid set valid attrs and aria attrs. Duplicate code
338
339
  handleValid() {
339
340
  // console.log('mip valid', this.modelItem.required);
340
341
 
341
342
  // console.log('late modelItem', mi);
342
- if (this.isValid() !== this.modelItem.constraint) {
343
- if (this.modelItem.constraint) {
343
+ const hasValue = this.modelItem.value !== '';
344
+ const isRequired = this.modelItem.required;
345
+ const isValidAccordingToRequired = isRequired ? hasValue : true;
346
+ const isValidNow = this.modelItem.constraint && isValidAccordingToRequired;
347
+
348
+ if (this.isValid() !== isValidNow) {
349
+ if (isValidNow) {
344
350
  // if (alert) alert.style.display = 'none';
345
351
  this._dispatchEvent('valid');
346
352
  this.setAttribute('valid', '');
347
353
  this.removeAttribute('invalid');
348
354
  this.getWidget().setAttribute('aria-invalid', 'false');
355
+ // also reset other dependent CSS classes
356
+ this.classList.remove('isEmpty');
349
357
  } else {
350
358
  this.setAttribute('invalid', '');
351
359
  this.getWidget().setAttribute('aria-invalid', 'true');
@@ -379,7 +387,6 @@ export default class AbstractControl extends UIElement {
379
387
  // Ensure aria-invalid matches the current control state even if
380
388
  // we didn't enter the state-change branch above.
381
389
  this._syncAriaInvalid();
382
-
383
390
  }
384
391
 
385
392
  handleRelevant() {
@@ -400,12 +407,12 @@ export default class AbstractControl extends UIElement {
400
407
 
401
408
  // Apply attributes
402
409
  if (newEnabled) {
403
- this.setAttribute('relevant', '');
410
+ this.setAttribute('relevant', '');
404
411
  this.removeAttribute('nonrelevant');
405
- } else {
406
- this.setAttribute('nonrelevant', '');
412
+ } else {
413
+ this.setAttribute('nonrelevant', '');
407
414
  this.removeAttribute('relevant');
408
- }
415
+ }
409
416
 
410
417
  // Dispatch only on actual change
411
418
  if (wasEnabled !== newEnabled) {
package/src/ui/fx-case.js CHANGED
@@ -65,6 +65,7 @@ export class FxCase extends FxContainer {
65
65
 
66
66
  this.addEventListener('select', async () => {
67
67
  const ownerForm = this.getOwnerForm();
68
+ let target = this;
68
69
  if (this.src) {
69
70
  // We will replace the node. So this node will be detached after these async function
70
71
  // calls. Save all important state first.
@@ -79,9 +80,10 @@ export class FxCase extends FxContainer {
79
80
  return;
80
81
  }
81
82
  await parentNode.replaceCase(this, replacement);
83
+ target = replacement;
82
84
  }
83
85
  const model = ownerForm.getModel();
84
- ownerForm.addToBatchedNotifications(this);
86
+ ownerForm.addToBatchedNotifications(target);
85
87
  ownerForm.refresh(false);
86
88
  });
87
89
  this.addEventListener('deselect', (event) => {
@@ -441,7 +441,7 @@ export default class FxControl extends XfAbstractControl {
441
441
  }
442
442
 
443
443
  // ### when there's a src Fore is used as widget and will be loaded from external file
444
- if (this.src && !this.loaded && this.modelItem.relevant) {
444
+ if (this.src && !this.loaded && !this.loading && this.modelItem.relevant) {
445
445
  // ### evaluate initial data if necessary
446
446
 
447
447
  if (this.initial) {
@@ -449,9 +449,11 @@ export default class FxControl extends XfAbstractControl {
449
449
  // console.log('initialNodes', this.initialNode);
450
450
  }
451
451
 
452
+ this.loading = true;
452
453
  // ### load the markup from src
453
454
  await this._loadForeFromSrc();
454
455
  this.loaded = true;
456
+ this.loading = false;
455
457
 
456
458
  // ### replace default instance of embedded Fore with initial nodes
457
459
  // const innerInstance = this.querySelector('fx-instance');
@@ -577,7 +579,7 @@ export default class FxControl extends XfAbstractControl {
577
579
  } finally {
578
580
  this._isRefreshing = false;
579
581
  }
580
- Fore.refreshChildren(this, force);
582
+ await Fore.refreshChildren(this, force);
581
583
  }
582
584
 
583
585
  /**
@@ -93,7 +93,7 @@ class FxGroup extends FxContainer {
93
93
  super.refresh(force);
94
94
  // Make the maybe filtered refresh an unconditional forced refresh: This fx-group changes the
95
95
  // context item
96
- Fore.refreshChildren(this, !!force);
96
+ return Fore.refreshChildren(this, !!force);
97
97
  }
98
98
 
99
99
  // todo: this code should go
@@ -119,49 +119,19 @@ export class FxOutput extends XfAbstractControl {
119
119
  // }
120
120
 
121
121
  if (this.mediatype === 'html') {
122
- if (this.modelItem.node) {
123
- const defaultSlot = this.shadowRoot.querySelector('#default');
124
- const { node } = this.modelItem;
125
- if (node.nodeType) {
126
- valueWrapper.append(node);
127
- // this.appendChild(node);
122
+ // JSON instances use a lens, so modelItem.node is null — fall back to this.value
123
+ const source = this.modelItem.node ?? this.value;
124
+ if (source) {
125
+ if (source.nodeType) {
126
+ valueWrapper.append(source);
128
127
  return;
129
128
  }
130
-
131
- // ### try to parse as string
132
- const tmpDoc = new DOMParser().parseFromString(node, 'text/html');
133
- const theNode = tmpDoc.body.childNodes;
134
- // console.log('actual node', theNode)
135
- Array.from(theNode).forEach(n => {
129
+ // parse string as HTML
130
+ const tmpDoc = new DOMParser().parseFromString(source, 'text/html');
131
+ Array.from(tmpDoc.body.childNodes).forEach(n => {
136
132
  valueWrapper.append(n);
137
133
  });
138
- // valueWrapper.append(theNode);
139
-
140
- // valueWrapper.innerHTML=node;
141
- /*
142
- if (node.nodeType) {
143
- this.appendChild(node);
144
- return;
145
- }
146
- Object.entries(node).map(obj => {
147
- // valueWrapper.appendChild(obj[1]);
148
- this.appendChild(obj[1]);
149
- });
150
- */
151
- /*
152
- Object.entries(node).map(obj => {
153
- // valueWrapper.appendChild(obj[1]);
154
- this.appendChild(obj[1]);
155
- });
156
- */
157
-
158
- return;
159
134
  }
160
-
161
- // this.innerHTML = this.value.outerHTML;
162
- // valueWrapper.innerHTML = this.value.outerHTML;
163
-
164
- // this.shadowRoot.appendChild(this.value);
165
135
  return;
166
136
  }
167
137
 
@@ -344,7 +344,7 @@ export class FxRepeatAttributes extends withDraggability(RepeatBase, false) {
344
344
  // Fore.refreshChildren(clone,true);
345
345
  const fore = this.getOwnerForm();
346
346
  if (!fore.lazyRefresh || force) {
347
- Fore.refreshChildren(this, force);
347
+ await Fore.refreshChildren(this, force);
348
348
  }
349
349
  // this.style.display = 'block';
350
350
  // this.style.display = this.display;
@@ -1035,7 +1035,7 @@ export class FxRepeat extends withDraggability(UIElement, false) {
1035
1035
 
1036
1036
  const fore = this.getOwnerForm();
1037
1037
  if (!fore.lazyRefresh || force) {
1038
- Fore.refreshChildren(this, force);
1038
+ await Fore.refreshChildren(this, force);
1039
1039
  }
1040
1040
 
1041
1041
  this.setIndex(this.index);
@@ -127,7 +127,7 @@ export class RepeatBase extends withDraggability(UIElement, false) {
127
127
  // if (!fore.lazyRefresh || force) {
128
128
  if (!fore.lazyRefresh || force) {
129
129
  // Turn the possibly conditional force refresh into a forced one: we changed our children
130
- Fore.refreshChildren(this, force);
130
+ await Fore.refreshChildren(this, force);
131
131
  }
132
132
  // this.style.display = 'block';
133
133
  // this.style.display = this.display;
@@ -477,9 +477,9 @@ export class RepeatBase extends withDraggability(UIElement, false) {
477
477
  // Create a ModelItem only for the final node; children never get their own opNum
478
478
  modelItem = FxBind.createModelItem(ref, node, child, null);
479
479
  modelItem.parentModelItem = parentModelItem;
480
- // IMPORTANT: keep using the canonical instance returned by registerModelItem.
481
- // Otherwise a throwaway ModelItem can leak into observer graphs and be notified.
482
- modelItem = this.getModel().registerModelItem(modelItem);
480
+ // IMPORTANT: keep using the canonical instance returned by registerModelItem.
481
+ // Otherwise a throwaway ModelItem can leak into observer graphs and be notified.
482
+ modelItem = this.getModel().registerModelItem(modelItem);
483
483
  }
484
484
 
485
485
  // Always apply Dewey rewrite (handles both $inst and instance('inst') forms)