@es-joy/jsoe 0.25.1 → 0.26.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.
@@ -270,65 +270,72 @@ const functionType = {
270
270
  }
271
271
  );
272
272
 
273
+ const buildFnTypeChoices =
274
+ /** @type {import('../typeChoices.js').BuildTypeChoices} */ (
275
+ buildTypeChoices
276
+ );
277
+ const argsChoices = buildFnTypeChoices({
278
+ // resultType,
279
+ topRoot: /** @type {HTMLDivElement} */ (topRoot),
280
+ format: 'schema', // We're always supplying a schema
281
+ // schemaOriginal: schemaContent,
282
+ schemaContent: args,
283
+ state: type,
284
+ // itemIndex,
285
+ typeNamespace
286
+ });
287
+ const bodyChoices = buildFnTypeChoices({
288
+ // resultType,
289
+ topRoot: /** @type {HTMLDivElement} */ (topRoot),
290
+ format: 'schema', // We're always supplying a schema
291
+ // schemaOriginal: schemaContent,
292
+ schemaContent: {type: 'string'},
293
+ state: type,
294
+ // itemIndex,
295
+ typeNamespace
296
+ });
297
+
273
298
  const div = jml('div', {dataset: {type: 'function'}}, [
274
299
  ['b', ['Arguments']],
275
300
  ['br'],
276
-
277
- ...(/** @type {import('../typeChoices.js').BuildTypeChoices} */ (
278
- buildTypeChoices
279
- )({
280
- // resultType,
281
- topRoot: /** @type {HTMLDivElement} */ (topRoot),
282
- format: 'schema', // We're always supplying a schema
283
- // schemaOriginal: schemaContent,
284
- schemaContent: args,
285
- state: type,
286
- // itemIndex,
287
- typeNamespace
288
- }).domArray),
289
-
301
+ ...argsChoices.domArray,
290
302
  ['b', ['Function body']],
291
303
  ['br'],
292
- ...(/** @type {import('../typeChoices.js').BuildTypeChoices} */ (
293
- buildTypeChoices
294
- )({
295
- // resultType,
296
- topRoot: /** @type {HTMLDivElement} */ (topRoot),
297
- format: 'schema', // We're always supplying a schema
298
- // schemaOriginal: schemaContent,
299
- schemaContent: {type: 'string'},
300
- state: type,
301
- // itemIndex,
302
- typeNamespace
303
- }).domArray)
304
+ ...bodyChoices.domArray
305
+ ]);
306
+
307
+ // Both type-choice controls build their edit UI (the argument and body
308
+ // textareas) a tick after they are connected. Wait on their `whenReady`
309
+ // rather than guessing with nested `setTimeout`s.
310
+ const whenControlsReady = Promise.all([
311
+ argsChoices.whenReady, bodyChoices.whenReady
304
312
  ]);
305
313
 
306
314
  if (value) {
307
- setTimeout(() => {
315
+ (async () => {
308
316
  // Fire-and-forget population; swallow rejections from a still-settling
309
317
  // editor so they cannot surface as an unhandled rejection.
310
- setValueAndTooltips({
311
- root: div,
312
- value,
313
- specificSchemaObject
314
- // eslint-disable-next-line promise/prefer-await-to-then -- Convenient
315
- }).catch(() => { /* best-effort */ });
316
- }, 0);
318
+ try {
319
+ await whenControlsReady;
320
+ await setValueAndTooltips({root: div, value, specificSchemaObject});
321
+ } catch {
322
+ // best-effort
323
+ }
324
+ })();
317
325
  } else if (specificSchemaObject) {
318
- setTimeout(() => {
319
- setTimeout(() => {
320
- // Should have some empty textareas by now
321
- const textareas = /** @type {HTMLTextAreaElement[]} */ (
322
- $$e(div, 'textarea')
323
- );
324
- const textareaBody = /** @type {HTMLTextAreaElement} */ (
325
- textareas.pop()
326
- );
327
- setTooltips({
328
- root: div, specificSchemaObject, textareas, textareaBody
329
- });
330
- }, 0);
331
- }, 0);
326
+ (async () => {
327
+ await whenControlsReady;
328
+ // The auto-triggered controls have their textareas by now.
329
+ const textareas = /** @type {HTMLTextAreaElement[]} */ (
330
+ $$e(div, 'textarea')
331
+ );
332
+ const textareaBody = /** @type {HTMLTextAreaElement} */ (
333
+ textareas.pop()
334
+ );
335
+ setTooltips({
336
+ root: div, specificSchemaObject, textareas, textareaBody
337
+ });
338
+ })();
332
339
  }
333
340
 
334
341
  return [div];
@@ -106,7 +106,15 @@ const regexpType = {
106
106
  'Source ',
107
107
  ['input', {
108
108
  name: `${typeNamespace}-regexp`, type: 'text',
109
- value: value.source
109
+ value: value.source,
110
+ $on: {
111
+ input () {
112
+ types.validate({type: 'regexp', root});
113
+ },
114
+ change () {
115
+ types.validate({type: 'regexp', root});
116
+ }
117
+ }
110
118
  }]
111
119
  ]],
112
120
  ['br'],
@@ -5,6 +5,7 @@ import {schemaLabel} from '../utils/schemaMeta.js';
5
5
 
6
6
  import dialogs from '../utils/dialogs.js';
7
7
  import {isNullish} from '../utils/types.js';
8
+ import {whenConnected} from '../utils/timing.js';
8
9
 
9
10
  /**
10
11
  * @typedef {HTMLTextAreaElement & {
@@ -146,14 +147,11 @@ const blobHTMLType = {
146
147
  // connected to the document. The caller attaches `root` after the build
147
148
  // pipeline returns, and re-parenting an SCEditor iframe reloads it, so we
148
149
  // wait here for connection rather than initialising eagerly (or letting
149
- // the build pipeline await us, which would deadlock).
150
- let connectionTries = 0;
151
- const createWhenConnected = () => {
152
- if (!textarea.isConnected) {
153
- /* istanbul ignore else -- Bounded guard against a never-attached UI */
154
- if (connectionTries++ < 250) {
155
- setTimeout(createWhenConnected, 20);
156
- }
150
+ // the build pipeline await us, which would deadlock). The wait is bounded
151
+ // (`whenConnected`) so a never-attached editor is abandoned, not polled
152
+ // forever.
153
+ (async () => {
154
+ if (!await whenConnected(textarea, 250)) {
157
155
  return;
158
156
  }
159
157
  // Push onto these: https://www.sceditor.com/documentation/formats/xhtml/
@@ -199,8 +197,7 @@ const blobHTMLType = {
199
197
  this
200
198
  ).setValue({root, value});
201
199
  }
202
- };
203
- setTimeout(createWhenConnected, 0);
200
+ })();
204
201
  return [root];
205
202
  }
206
203
  };
@@ -6,33 +6,9 @@ import {$e, DOM} from './utils/templateUtils.js';
6
6
  import {isUnionLike} from './utils/types.js';
7
7
  import {getXorBranchMatchInfo, valueMatchesSchema} from './formats/schema.js';
8
8
  import dialogs from './utils/dialogs.js';
9
- import {tick} from './utils/timing.js';
9
+ import {whenConnected} from './utils/timing.js';
10
10
  import deepEqual from 'fast-deep-equal/es6/index.js';
11
11
 
12
- /**
13
- * A deferred `setValue`/auto-select runs a tick after the control is built, but
14
- * the caller may only connect it to the document on a deferred tick of its own
15
- * (e.g. `setTimeout(0)` DOM insertion). Wait this many ticks for the connection
16
- * before giving up, rather than dropping the pre-set value on the first miss.
17
- */
18
- const MAX_CONNECT_WAIT_TICKS = 50;
19
-
20
- /**
21
- * @param {HTMLSelectElement} sel
22
- * @returns {Promise<boolean>} Whether `sel` became connected within the bound.
23
- */
24
- const waitForConnection = async (sel) => {
25
- for (
26
- let waited = 0;
27
- !sel.isConnected && waited < MAX_CONNECT_WAIT_TICKS;
28
- waited++
29
- ) {
30
- // eslint-disable-next-line no-await-in-loop -- Sequential by design
31
- await tick();
32
- }
33
- return sel.isConnected;
34
- };
35
-
36
12
  // This is technically just `import('./index.js').SetType`, but our
37
13
  // redirect file causes problems, so we redefine here
38
14
  /**
@@ -85,6 +61,10 @@ const waitForConnection = async (sel) => {
85
61
  * @typedef {(info: {editUI: HTMLElement}) => void} AddEditUI
86
62
  */
87
63
 
64
+ /**
65
+ * @typedef {() => Promise<void>} WhenReady
66
+ */
67
+
88
68
  /**
89
69
  * @typedef {HTMLSelectElement & {
90
70
  * $addAndValidateEditUI: AddAndValidateEditUI,
@@ -94,7 +74,8 @@ const waitForConnection = async (sel) => {
94
74
  * $getTopRoot: () => HTMLDivElement,
95
75
  * $addEditUI: AddEditUI,
96
76
  * $validate: Validate,
97
- * $setTypeNoEditUI: SetTypeNoEditUI
77
+ * $setTypeNoEditUI: SetTypeNoEditUI,
78
+ * $whenReady: WhenReady
98
79
  * }} TypeChoicesElementAPI
99
80
  */
100
81
 
@@ -149,7 +130,8 @@ const waitForConnection = async (sel) => {
149
130
  * getValue: GetValue,
150
131
  * getType: GetType,
151
132
  * validValuesSet: ValidValuesSet,
152
- * setValue: SetValue
133
+ * setValue: SetValue,
134
+ * whenReady: Promise<void>
153
135
  * }}
154
136
  */
155
137
 
@@ -162,7 +144,7 @@ const waitForConnection = async (sel) => {
162
144
  const typeChoicesAPIMethods = [
163
145
  '$getValue', '$setType', '$setTypeNoEditUI', '$setStyles', '$getTypeRoot',
164
146
  '$addAndValidateEditUI', '$addTypeAndEditUI', '$addEditUI', '$getContainer',
165
- '$getTopRoot', '$validate'
147
+ '$getTopRoot', '$validate', '$whenReady'
166
148
  ];
167
149
 
168
150
  /**
@@ -413,6 +395,25 @@ export const buildTypeChoices = ({
413
395
 
414
396
  let editUI;
415
397
 
398
+ /**
399
+ * `whenReady` resolves once the deferred (auto-trigger / `setValue`) build
400
+ * work scheduled at the end of this function has finished and its edit UI
401
+ * is in place — or on the next tick when there is no deferred work.
402
+ * Callers that need to read from, validate, or drive the control
403
+ * immediately after building it can `await` it instead of guessing with a
404
+ * `setTimeout`. It always settles (the connection wait is bounded — see
405
+ * `whenConnected` — so it resolves even if the control never connects),
406
+ * so awaiting it cannot hang.
407
+ * @type {() => void}
408
+ */
409
+ let markReady;
410
+ const whenReady = /** @type {Promise<void>} */ (
411
+ // eslint-disable-next-line promise/avoid-new, unicorn/prefer-promise-with-resolvers -- Target lib predates `Promise.withResolvers`
412
+ new Promise((resolve) => {
413
+ markReady = resolve;
414
+ })
415
+ );
416
+
416
417
  /** @type {GetValue} */
417
418
  const $getValue = (stateObj, currentPath) => {
418
419
  const root = /** @type {HTMLDivElement} */ (
@@ -443,6 +444,12 @@ export const buildTypeChoices = ({
443
444
  // is: 'type-choices',
444
445
  $custom: {
445
446
  $getValue,
447
+ /**
448
+ * @type {WhenReady}
449
+ */
450
+ $whenReady () {
451
+ return whenReady;
452
+ },
446
453
  /**
447
454
  * @this {TypeChoicesElementAPI}
448
455
  * @param {Parameters<SetType>[0]} cfg
@@ -464,7 +471,12 @@ export const buildTypeChoices = ({
464
471
  this.value = type;
465
472
  }
466
473
  this.$setStyles();
467
- this.$addAndValidateEditUI({baseValue, bringIntoFocus, avoidReport});
474
+ this.$addAndValidateEditUI({
475
+ baseValue,
476
+ bringIntoFocus,
477
+ schemaObject: specificSchema,
478
+ avoidReport
479
+ });
468
480
  },
469
481
  /**
470
482
  * @this {TypeChoicesElementAPI}
@@ -544,7 +556,7 @@ export const buildTypeChoices = ({
544
556
  typeNamespace,
545
557
  type: /** @type {import('./types.js').AvailableArbitraryType} */ (type),
546
558
  bringIntoFocus,
547
- hasValue: type === 'arrayNonindexKeys' && baseValue,
559
+ hasValue: baseValue !== undefined,
548
560
  value: baseValue,
549
561
  buildTypeChoices,
550
562
  format,
@@ -581,7 +593,7 @@ export const buildTypeChoices = ({
581
593
  });
582
594
  topRoot = this.$getTopRoot(); // May be existing now
583
595
  // Needed; Array/object ref somewhere could now be valid or invalid
584
- types.validateAllReferences({topRoot});
596
+ types.validateAllReferences({topRoot, avoidReport});
585
597
  },
586
598
 
587
599
  /**
@@ -689,62 +701,81 @@ export const buildTypeChoices = ({
689
701
 
690
702
  if (autoTrigger && !setValue && typeOptions.length === 1) {
691
703
  setTimeout(async () => {
692
- if (!await waitForConnection(sel)) {
693
- return;
704
+ try {
705
+ if (!await whenConnected(sel)) {
706
+ return;
707
+ }
708
+ sel.selectedIndex = 1;
709
+ sel.dispatchEvent(new Event('change'));
710
+ } finally {
711
+ markReady();
694
712
  }
695
- sel.selectedIndex = 1;
696
- sel.dispatchEvent(new Event('change'));
697
713
  }, 0);
698
714
  } else if (setValue || (requireObject && !objectHasValue)) {
699
715
  setTimeout(async () => {
700
- if (!await waitForConnection(sel)) {
701
- return;
702
- }
703
- if (!setValue) { // if (requireObject && !objectHasValue) {
704
- // Todo (low): We could auto-populate keypath if has
705
- // keypath (and we probably also only want if
706
- // not autoincrement)
707
- value = {};
708
- }
709
716
  try {
710
- const {
711
- rootUI: rootEditUI,
712
- specificSchemas
713
- } = await formats.getControlsForFormatAndValue(
714
- types,
715
- format,
716
- value,
717
- {
718
- readonly: false,
719
- typeNamespace,
720
- schema,
721
- schemaContent,
722
- formats,
723
- types
724
- }
725
- );
726
- const type =
727
- Types.getTypeForRoot(/** @type {HTMLDivElement} */ (
728
- rootEditUI
729
- ));
730
- /** @type {HTMLSelectElement & {$addTypeAndEditUI: AddTypeAndEditUI}} */ (
731
- sel
732
- ).$addTypeAndEditUI({
733
- type,
734
- editUI: rootEditUI,
735
- // We do actually want the first one
736
- specificSchema: specificSchemas?.[0]
737
- });
738
- } catch (err) {
739
- /* istanbul ignore next -- At least some errors handled earlier */
740
- dialogs.alert({
741
- message: 'The object to be added had types not supported ' +
742
- 'by the current format.'
743
- });
744
- /* istanbul ignore next -- How to trigger? */
745
- console.log('err', err);
717
+ if (!await whenConnected(sel)) {
718
+ return;
719
+ }
720
+ if (!setValue) { // if (requireObject && !objectHasValue) {
721
+ // Todo (low): We could auto-populate keypath if has
722
+ // keypath (and we probably also only want if
723
+ // not autoincrement)
724
+ value = {};
725
+ }
726
+ try {
727
+ const builtState = await formats.getControlsForFormatAndValue(
728
+ types,
729
+ format,
730
+ value,
731
+ {
732
+ readonly: false,
733
+ typeNamespace,
734
+ schema,
735
+ schemaContent,
736
+ formats,
737
+ types
738
+ }
739
+ );
740
+ const {
741
+ rootUI: rootEditUI,
742
+ specificSchemas
743
+ } = builtState;
744
+ const type =
745
+ Types.getTypeForRoot(/** @type {HTMLDivElement} */ (
746
+ rootEditUI
747
+ ));
748
+ /**
749
+ * @type {HTMLSelectElement & {$addTypeAndEditUI: AddTypeAndEditUI}}
750
+ */ (
751
+ sel
752
+ ).$addTypeAndEditUI({
753
+ type,
754
+ editUI: rootEditUI,
755
+ // We do actually want the first one
756
+ specificSchema: specificSchemas?.[0]
757
+ });
758
+ await builtState.whenBuilt;
759
+ } catch (err) {
760
+ /* istanbul ignore next -- At least some errors handled earlier */
761
+ dialogs.alert({
762
+ message: 'The object to be added had types not supported ' +
763
+ 'by the current format.'
764
+ });
765
+ /* istanbul ignore next -- How to trigger? */
766
+ console.log('err', err);
767
+ }
768
+ } finally {
769
+ markReady();
746
770
  }
747
771
  }, 0);
772
+ } else {
773
+ // No deferred build work; still settle on a later tick so `$whenReady()`
774
+ // behaves consistently for callers that connect `sel` on a deferred
775
+ // tick of their own.
776
+ setTimeout(() => {
777
+ markReady();
778
+ }, 0);
748
779
  }
749
780
 
750
781
  return {
@@ -753,6 +784,13 @@ export const buildTypeChoices = ({
753
784
  typeContainer
754
785
  ],
755
786
 
787
+ /**
788
+ * Settles once the deferred build work (if any) scheduled above has
789
+ * finished; `await` it instead of guessing a `setTimeout` delay.
790
+ * @type {Promise<void>}
791
+ */
792
+ whenReady,
793
+
756
794
  /** @type {GetValue} */
757
795
  getValue: $getValue,
758
796
 
package/src/types.js CHANGED
@@ -202,7 +202,8 @@ export const getPropertyValueFromLegend = (legend) => {
202
202
 
203
203
  /**
204
204
  * @typedef {(cfg: {
205
- * topRoot: RootElement
205
+ * topRoot: RootElement,
206
+ * avoidReport?: boolean
206
207
  * }) => void} ValidateAllReferences
207
208
  */
208
209
 
@@ -359,6 +360,7 @@ export const getPropertyValueFromLegend = (legend) => {
359
360
  * buildTypeChoices?: import('./typeChoices.js').BuildTypeChoices,
360
361
  * schemaContent?: import('./formats/schema.js').ZodexSchema
361
362
  * replaced?: any
363
+ * hasValue?: boolean
362
364
  * }) => JamilihArray} viewUI
363
365
  * @property {(info: {
364
366
  * value?: StructuredCloneValue,
@@ -374,7 +376,8 @@ export const getPropertyValueFromLegend = (legend) => {
374
376
  * topRoot?: HTMLDivElement
375
377
  * schemaContent?: import('./formats/schema.js').ZodexSchema,
376
378
  * specificSchemaObject?: import('./formats/schema.js').ZodexSchema,
377
- * schemaFallingBack?: boolean
379
+ * schemaFallingBack?: boolean,
380
+ * hasValue?: boolean
378
381
  * }) => JamilihArray} editUI
379
382
  * @property {(info: {root: HTMLDivElement}) =>
380
383
  * HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement|
@@ -391,7 +394,8 @@ export const getPropertyValueFromLegend = (legend) => {
391
394
  * }} [validate] Message will be used if validity is false.
392
395
  * @property {(info: {
393
396
  * topRoot: HTMLDivElement,
394
- * types: Types
397
+ * types: Types,
398
+ * avoidReport?: boolean
395
399
  * }) => void} [validateAll] For
396
400
  * validation of array and object references only.
397
401
  * @property {{
@@ -766,7 +770,7 @@ class Types {
766
770
  format, schemaContent,
767
771
  resultType, topRoot, bringIntoFocus, value,
768
772
  replaced,
769
- specificSchemaObject, schemaFallingBack,
773
+ specificSchemaObject, schemaFallingBack, hasValue,
770
774
  types: this
771
775
  }
772
776
  : {
@@ -774,7 +778,7 @@ class Types {
774
778
  format, schemaContent,
775
779
  resultType, topRoot, bringIntoFocus,
776
780
  replaced,
777
- specificSchemaObject, schemaFallingBack,
781
+ specificSchemaObject, schemaFallingBack, hasValue,
778
782
  types: this
779
783
  };
780
784
  const root = /** @type {HTMLDivElement} */ (jml(
@@ -796,7 +800,7 @@ class Types {
796
800
  }
797
801
 
798
802
  /** @type {ValidateAllReferences} */
799
- validateAllReferences ({topRoot}) {
803
+ validateAllReferences ({topRoot, avoidReport}) {
800
804
  /* istanbul ignore if -- Unreachable? */
801
805
  if (!topRoot) {
802
806
  console.log('No references present');
@@ -808,7 +812,7 @@ class Types {
808
812
  Object.values(this.availableTypes).forEach((typeObj) => {
809
813
  const typeObject = /** @type {TypeObject} */ (typeObj);
810
814
  if (typeObject.validateAll) {
811
- typeObject.validateAll({topRoot, types: this});
815
+ typeObject.validateAll({topRoot, types: this, avoidReport});
812
816
  }
813
817
  });
814
818
 
@@ -858,7 +862,7 @@ class Types {
858
862
  'message' in error
859
863
  ? error.message
860
864
  : undefined;
861
- if (errorMessage !== 'Not yet instantiated') {
865
+ if (errorMessage !== 'Not yet instantiated' && typeValidation.valid) {
862
866
  return false;
863
867
  }
864
868
  valueReady = false;
@@ -10,3 +10,34 @@
10
10
  export const tick = () => new Promise((resolve) => {
11
11
  setTimeout(resolve, 0);
12
12
  });
13
+
14
+ /**
15
+ * Default upper bound, in {@link tick}s, for {@link whenConnected}. A control
16
+ * that is never attached to the document should be given up on rather than
17
+ * awaited forever.
18
+ */
19
+ export const MAX_CONNECT_WAIT_TICKS = 50;
20
+
21
+ /**
22
+ * Resolves once `el` is connected to the document, re-checking once per
23
+ * macrotask via {@link tick}. A control is built synchronously, but its
24
+ * consumer may only attach it a deferred tick later (e.g. a `setTimeout(0)`
25
+ * DOM insertion), and connection-dependent initialisation — an SCEditor
26
+ * iframe, an auto-`setValue`, a type-choices auto-trigger — has to wait for
27
+ * that. Bounded so a control that is never attached is abandoned rather than
28
+ * polled forever; the resolved value says which happened.
29
+ * @param {Element} el
30
+ * @param {number} [maxTicks]
31
+ * @returns {Promise<boolean>} Whether `el` became connected within the bound.
32
+ */
33
+ export const whenConnected = async (el, maxTicks = MAX_CONNECT_WAIT_TICKS) => {
34
+ for (
35
+ let waited = 0;
36
+ !el.isConnected && waited < maxTicks;
37
+ waited++
38
+ ) {
39
+ // eslint-disable-next-line no-await-in-loop -- Sequential by design
40
+ await tick();
41
+ }
42
+ return el.isConnected;
43
+ };