@fieldia/viewer 0.17.0 → 0.19.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 (54) hide show
  1. package/README.md +1 -1
  2. package/bundle/fieldia.ar.js +2 -2
  3. package/bundle/fieldia.ar.js.map +2 -2
  4. package/bundle/fieldia.de.js +2 -2
  5. package/bundle/fieldia.de.js.map +2 -2
  6. package/bundle/fieldia.fr.js +2 -2
  7. package/bundle/fieldia.fr.js.map +2 -2
  8. package/bundle/fieldia.js +16 -16
  9. package/bundle/fieldia.js.map +4 -4
  10. package/package.json +3 -3
  11. package/src/lib/dialog.js +2 -2
  12. package/src/lib/dialog.js.map +1 -1
  13. package/src/lib/dialog.mjs +2 -2
  14. package/src/lib/dialog.mjs.map +1 -1
  15. package/src/lib/labels.d.mts +8 -0
  16. package/src/lib/labels.d.ts +8 -0
  17. package/src/lib/labels.js +3 -0
  18. package/src/lib/labels.js.map +1 -1
  19. package/src/lib/labels.mjs +3 -0
  20. package/src/lib/labels.mjs.map +1 -1
  21. package/src/lib/list.js +2 -5
  22. package/src/lib/list.js.map +1 -1
  23. package/src/lib/list.mjs +3 -6
  24. package/src/lib/list.mjs.map +1 -1
  25. package/src/lib/locales/ar.js +3 -0
  26. package/src/lib/locales/ar.js.map +1 -1
  27. package/src/lib/locales/ar.mjs +3 -0
  28. package/src/lib/locales/ar.mjs.map +1 -1
  29. package/src/lib/locales/de.js +3 -0
  30. package/src/lib/locales/de.js.map +1 -1
  31. package/src/lib/locales/de.mjs +3 -0
  32. package/src/lib/locales/de.mjs.map +1 -1
  33. package/src/lib/locales/fr.js +3 -0
  34. package/src/lib/locales/fr.js.map +1 -1
  35. package/src/lib/locales/fr.mjs +3 -0
  36. package/src/lib/locales/fr.mjs.map +1 -1
  37. package/src/lib/look.d.mts +3 -3
  38. package/src/lib/look.d.ts +3 -3
  39. package/src/lib/look.js +1 -1
  40. package/src/lib/look.js.map +1 -1
  41. package/src/lib/look.mjs +1 -1
  42. package/src/lib/look.mjs.map +1 -1
  43. package/src/lib/place.d.mts +4 -0
  44. package/src/lib/place.d.ts +4 -0
  45. package/src/lib/place.js +2 -0
  46. package/src/lib/place.js.map +1 -1
  47. package/src/lib/place.mjs +2 -0
  48. package/src/lib/place.mjs.map +1 -1
  49. package/src/lib/viewer.d.mts +13 -3
  50. package/src/lib/viewer.d.ts +13 -3
  51. package/src/lib/viewer.js +152 -16
  52. package/src/lib/viewer.js.map +1 -1
  53. package/src/lib/viewer.mjs +152 -17
  54. package/src/lib/viewer.mjs.map +1 -1
@@ -1,5 +1,5 @@
1
1
  import { createForm, checkPage, isRightToLeft, localizePage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
2
- import { browserPreferences, createWidget, displayValue, drawIcon, installStyles, readsAsText, readText, WIDGET_LABELS } from '@fieldia/widgets';
2
+ import { browserPreferences, createWidget, displayValue, drawIcon, installStyles, readsAsText, readText, wearsQuiet, WIDGET_LABELS } from '@fieldia/widgets';
3
3
  import { findPage, pageDialogs } from './related.mjs';
4
4
  import { listView } from './list.mjs';
5
5
  import { applyLook, skinFor, wearTokens } from './look.mjs';
@@ -111,7 +111,7 @@ export function mountViewer(host, given) {
111
111
  return icon ? [icon, words] : [words];
112
112
  }
113
113
  const theme = options.theme ?? page.look?.theme;
114
- const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': skinFor(options.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
114
+ const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': skinFor(options.skin ?? page.look?.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
115
115
  applyLook(root, theme ? { ...page.look, theme } : page.look);
116
116
  wearTokens(root, options.tokens);
117
117
  const confirm = options.confirm ?? dialogConfirm;
@@ -146,6 +146,8 @@ export function mountViewer(host, given) {
146
146
  // ---- the parts -------------------------------------------------------
147
147
  /** The fields that take the focus as the record opens, in the order they are drawn. */
148
148
  const focusers = [];
149
+ /** Each field's face, put on again when the form changes skin: quiet, or in its box. */
150
+ const faces = [];
149
151
  /** Folded sections, and how to open each: a problem inside one has to be seen. */
150
152
  const folds = new Map();
151
153
  /** Every part drawn again from the state of the page shown. */
@@ -169,8 +171,11 @@ export function mountViewer(host, given) {
169
171
  const uid = (id) => ownUid(scope.prefix + id);
170
172
  /** A part's updater, handed the state of the part's own form. */
171
173
  const watch = (update) => sink.push(scope.root ? update : () => update(form.getState()));
172
- /** A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none. */
173
- function fieldItem(node, place) {
174
+ /**
175
+ * A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none.
176
+ * `around` says what the groups round it say of the quiet look: quiet or boxed, and why their fields are read-only.
177
+ */
178
+ function fieldItem(node, place, around = {}) {
174
179
  const def = page.fields[node.field];
175
180
  const id = uid(node.id);
176
181
  const labelsAt = labelPlace(node, def.type, place);
@@ -196,6 +201,39 @@ export function mountViewer(host, given) {
196
201
  // Words chosen by a condition (placeholderWhen), else what the box showed as it was drawn.
197
202
  const drawnPlaceholder = textBox?.getAttribute('placeholder') ?? null;
198
203
  const chosenPlaceholder = node.placeholderWhen?.length ? textBox : null;
204
+ // The quiet look: its own word, else its group's, else the form's skin. In a quiet place its label is quiet; its value
205
+ // wears the look (data-quiet) unless its widget keeps its own face.
206
+ const quietOwn = node.quiet ?? around.quiet;
207
+ const wears = wearsQuiet(def, node);
208
+ const quietPlace = () => quietOwn ?? root.getAttribute('data-fd-skin') === 'quiet';
209
+ // Empty and quiet, it says what to add, unless the page gives it words of its own: "Add story points", or for a
210
+ // question "Your answer". A field of several boxes, each with its own label (an address), says it in none of them.
211
+ const ownWords = !!node.placeholder || labelsAt === 'hidden' || def.type === 'json' || def.type === 'properties';
212
+ const hint = quietHint(labelText, locale, labels);
213
+ const restWords = () => (!ownWords && wrapper.hasAttribute('data-quiet') ? hint : drawnPlaceholder);
214
+ // Where no box can hold a placeholder (a choice's blank, a state's words), the page's own words come first.
215
+ const blankWords = node.placeholder || (labelsAt === 'hidden' ? labelText : hint);
216
+ const quietSelect = widget.element instanceof HTMLSelectElement ? widget.element.querySelector(':scope > option[value=""]') : null;
217
+ const richText = widget.element.matches('.fd-richtext') ? widget.element : widget.element.querySelector(':scope > .fd-richtext');
218
+ const stateWords = widget.element.querySelector(':scope > .fd-dot-label');
219
+ const face = () => {
220
+ const place = quietPlace();
221
+ const quiet = wears && place;
222
+ wrapper.toggleAttribute('data-in-quiet', place);
223
+ wrapper.toggleAttribute('data-quiet', quiet);
224
+ if (textBox && !chosenPlaceholder)
225
+ setAttr(textBox, 'placeholder', restWords());
226
+ if (quietSelect)
227
+ quietSelect.textContent = quiet ? blankWords : '';
228
+ if (richText && !node.placeholder) {
229
+ setAttr(richText, 'data-placeholder', quiet ? hint : null);
230
+ setAttr(richText, 'aria-placeholder', quiet ? hint : null);
231
+ }
232
+ if (stateWords)
233
+ setAttr(stateWords, 'data-hint', quiet ? blankWords : null);
234
+ };
235
+ faces.push(face);
236
+ face();
199
237
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
200
238
  // `for` stays: a custom field that puts the id on its own input is
201
239
  // labelled natively. Only a wrapper with a role (a radio group, say) may
@@ -205,7 +243,7 @@ export function mountViewer(host, given) {
205
243
  named?.setAttribute('aria-labelledby', label.id);
206
244
  // Not a press on its (?) or the help bubble beside it: they show the help, and must not open a picker's list.
207
245
  label.addEventListener('click', (event) => {
208
- if (!event.target.closest?.('.fd-help-tip-wrap'))
246
+ if (!event.target.closest?.('.fd-help-tip-wrap, .fd-lock-wrap'))
209
247
  widget.focus();
210
248
  });
211
249
  }
@@ -222,6 +260,33 @@ export function mountViewer(host, given) {
222
260
  label.append(tip.element);
223
261
  label.dir = 'auto';
224
262
  }
263
+ // A rule keeping it read-only: a lock by its label (after the box when the label is out of sight) in the quiet look,
264
+ // and in any skin when the page says why — why, it says in a bubble, as the (?) does, pointed at or focused. Made
265
+ // the first time it is needed: a field never locked has none.
266
+ const reason = node.readonlyReason ?? around.reason;
267
+ // Read-only by its nature — worked out from others, or always read-only where the page puts it — a value is shown,
268
+ // not locked: it has a lock only when the page says why.
269
+ const fixed = node.readonly === true || def.readonly === true || def.compute !== undefined;
270
+ let lock = null;
271
+ let lockMark = null;
272
+ let lockedNow = false;
273
+ const lockFor = () => {
274
+ if (lockMark)
275
+ return lockMark;
276
+ const icon = drawIcon(doc, 'lock', options.icons) ?? '';
277
+ lock = reason ? bubbleTip(`${id}-lock`, reason, fill(labels.locked, { label: labelText }), 'fd-lock', icon) : null;
278
+ lockMark = lock?.element ?? el('span', { class: 'fd-lock-wrap' }, el('span', { class: 'fd-lock', 'aria-hidden': 'true' }, icon));
279
+ if (labelsAt === 'hidden')
280
+ widget.element.after(lockMark);
281
+ else
282
+ label.insertBefore(lockMark, tip && labelsAt !== 'after' ? tip.element : null);
283
+ return lockMark;
284
+ };
285
+ // Pointing at a locked quiet value, or focusing it, says why too.
286
+ const point = (on) => lock?.point(on && lockedNow && wrapper.hasAttribute('data-in-quiet'));
287
+ if (reason)
288
+ for (const [event, on] of [['mouseenter', true], ['mouseleave', false], ['focusin', true], ['focusout', false]])
289
+ widget.element.addEventListener(event, () => point(on));
225
290
  // Not an alert of its own: a refused save is announced once, naming every field to look at.
226
291
  const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
227
292
  // A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
@@ -231,6 +296,9 @@ export function mountViewer(host, given) {
231
296
  const asText = page.look?.readonlyShown === 'text' && readsAsText(def, node) ? readText({ document: doc, field: def, node, id, locale, labels: widgetLabels, dialogs }) : null;
232
297
  if (asText)
233
298
  widget.element.after(asText.element);
299
+ if (asText && reason)
300
+ for (const [event, on] of [['mouseenter', true], ['mouseleave', false]])
301
+ asText.element.addEventListener(event, () => point(on));
234
302
  if (widget.destroy)
235
303
  cleanups.push(() => widget.destroy?.());
236
304
  // An answer rule's warning waits until the person leaves the field: no advice
@@ -246,6 +314,22 @@ export function mountViewer(host, given) {
246
314
  // (a deleted line's button), and that redraw must finish first.
247
315
  queueMicrotask(() => update(form.getState()));
248
316
  });
317
+ // Quiet, Escape puts back the value the field had as it was entered — once its own list or calendar, if open, has
318
+ // taken the key — and only then is it left for the dialog round it. Leaving keeps the change, for Save.
319
+ let entered;
320
+ wrapper.addEventListener('focusin', (event) => {
321
+ if (!wrapper.contains(event.relatedTarget))
322
+ entered = form.getState().values[node.field];
323
+ });
324
+ wrapper.addEventListener('keydown', (event) => {
325
+ if (event.key !== 'Escape' || event.defaultPrevented || !wrapper.hasAttribute('data-quiet'))
326
+ return;
327
+ if (JSON.stringify(form.getState().values[node.field] ?? null) === JSON.stringify(entered ?? null))
328
+ return;
329
+ event.preventDefault();
330
+ event.stopPropagation();
331
+ form.setValue(node.field, (entered ?? null));
332
+ });
249
333
  const toned = node.tones !== undefined || node.bold !== undefined;
250
334
  const update = (state) => {
251
335
  const shown = form.node(node.id);
@@ -258,7 +342,7 @@ export function mountViewer(host, given) {
258
342
  wrapper.classList.toggle('fd-value-bold', look.bold);
259
343
  }
260
344
  if (chosenPlaceholder)
261
- setAttr(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? drawnPlaceholder);
345
+ setAttr(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? restWords());
262
346
  const message = state.errors[node.field];
263
347
  setHidden(error, !message);
264
348
  setText(error, message ?? '');
@@ -277,6 +361,16 @@ export function mountViewer(host, given) {
277
361
  mark.toggleAttribute('hidden', !valid);
278
362
  }
279
363
  const readonly = shown.readonly || locked || scope.locked();
364
+ // Locked by a rule, not by the whole form being read: its lock shows, in the quiet look or with its reason, and its words are muted.
365
+ const quiet = wrapper.hasAttribute('data-quiet');
366
+ lockedNow = readonly && !locked;
367
+ const showsLock = lockedNow && (!!reason || (wrapper.hasAttribute('data-in-quiet') && !fixed));
368
+ if (showsLock || lockMark)
369
+ setHidden(lockFor(), !showsLock);
370
+ if (!lockedNow)
371
+ point(false);
372
+ setAttr(wrapper, 'data-locked', quiet && showsLock ? '' : null);
373
+ setAttr(wrapper, 'data-empty', quiet && isBlank(state.values[node.field]) ? '' : null);
280
374
  if (asText) {
281
375
  setHidden(widget.element, readonly);
282
376
  setHidden(asText.element, !readonly);
@@ -290,7 +384,7 @@ export function mountViewer(host, given) {
290
384
  readonly,
291
385
  required: shown.required,
292
386
  invalid: !!message,
293
- describedBy: [help?.id ?? tip?.bubble.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
387
+ describedBy: [help?.id ?? tip?.bubble.id, lock && lockedNow ? lock.bubble.id : undefined, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
294
388
  });
295
389
  };
296
390
  watch(update);
@@ -298,8 +392,18 @@ export function mountViewer(host, given) {
298
392
  }
299
393
  /** A (?) that shows a field's help in a bubble: on hover, on focus, or kept open by a press — a tap — and gone with Escape. */
300
394
  function helpTip(bubbleId, text, name) {
395
+ return bubbleTip(bubbleId, text, name, 'fd-help-tip', '?');
396
+ }
397
+ /**
398
+ * A small button with words in a bubble — a field's help behind its (?), why it is locked behind its lock — shown
399
+ * on hover, on focus, or kept open by a press, and gone with Escape. `point` shows it from elsewhere: its value pointed at.
400
+ */
401
+ function bubbleTip(bubbleId, text, name, kind, face) {
301
402
  const bubble = el('span', { class: 'fd-help-bubble', role: 'tooltip', id: bubbleId, hidden: '' }, text);
302
- const button = el('button', { type: 'button', class: 'fd-help-tip', 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, '?');
403
+ const button = el('button', { type: 'button', class: kind, 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, face);
404
+ // A lock's reason is said as it is reached; the help waits to be asked for.
405
+ if (kind === 'fd-lock')
406
+ button.setAttribute('aria-describedby', bubbleId);
303
407
  let kept = false;
304
408
  const show = (open) => {
305
409
  bubble.hidden = !open;
@@ -324,7 +428,7 @@ export function mountViewer(host, given) {
324
428
  kept = false;
325
429
  show(false);
326
430
  });
327
- return { element: el('span', { class: 'fd-help-tip-wrap' }, button, bubble), bubble };
431
+ return { element: el('span', { class: kind === 'fd-lock' ? 'fd-lock-wrap' : 'fd-help-tip-wrap' }, button, bubble), bubble, point: (on) => show(on || kept) };
328
432
  }
329
433
  /** A bubble hangs from its (?): near the edge of a phone's screen it runs past it, so it is moved back in. */
330
434
  function keepOnScreen(bubble) {
@@ -423,7 +527,7 @@ export function mountViewer(host, given) {
423
527
  const line = el('div', { class: 'fd-field fd-oneline', 'data-node': node.id, 'data-style': 'inline', 'data-labels': place.labels });
424
528
  spans(line, node.colspan);
425
529
  const row = el('div', { class: 'fd-oneline-row', role: 'group' }, ...onelineRuns(node.children).map((run) => {
426
- const parts = run.map((child) => item(child, { columns: 1, onPage: false, inline: true }));
530
+ const parts = run.map((child) => item(child, { columns: 1, onPage: false, inline: true, quiet: node.quiet ?? place.quiet, reason: node.readonlyReason ?? place.reason }));
427
531
  return parts.length === 1 ? parts[0] : el('span', { class: 'fd-oneline-pair' }, ...parts);
428
532
  }));
429
533
  if (node.title) {
@@ -433,6 +537,10 @@ export function mountViewer(host, given) {
433
537
  }
434
538
  line.append(row);
435
539
  hideWhen(line, node.id);
540
+ // In a quiet place the line's label is quiet, as its fields' are.
541
+ const face = () => line.toggleAttribute('data-in-quiet', node.quiet ?? place.quiet ?? root.getAttribute('data-fd-skin') === 'quiet');
542
+ faces.push(face);
543
+ face();
436
544
  return line;
437
545
  }
438
546
  function sectionItem(node, place) {
@@ -514,7 +622,7 @@ export function mountViewer(host, given) {
514
622
  sink = own;
515
623
  try {
516
624
  // A tab's parts sit where the tabs do: on the page, or in the box round them.
517
- panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
625
+ panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels, quiet: place.quiet, reason: place.reason }));
518
626
  }
519
627
  finally {
520
628
  sink = outer;
@@ -610,7 +718,7 @@ export function mountViewer(host, given) {
610
718
  function partItem(node, place) {
611
719
  switch (node.type) {
612
720
  case 'field':
613
- return fieldItem(node, place.labels);
721
+ return fieldItem(node, place.labels, place);
614
722
  case 'button':
615
723
  return buttonItem(node);
616
724
  case 'text':
@@ -731,7 +839,7 @@ export function mountViewer(host, given) {
731
839
  });
732
840
  const layout = saved.layout;
733
841
  // Its parts sit in the part's box: a card there draws none of its own.
734
- note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels }));
842
+ note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels, quiet: place.quiet }));
735
843
  box.removeAttribute('aria-busy');
736
844
  cleanups.push(inner.subscribe(renderNow));
737
845
  if (mounted)
@@ -1446,11 +1554,13 @@ export function mountViewer(host, given) {
1446
1554
  void submitOrSave();
1447
1555
  return;
1448
1556
  }
1557
+ const from = event.target;
1558
+ // A one-line code editor is a text box to the form: it says it is one line, and keeps Enter only from adding one.
1559
+ const oneLine = from.getAttribute('aria-multiline') === 'false';
1449
1560
  // A plain Enter a field used itself, such as picking from an open list, is the field's.
1450
- if (event.defaultPrevented || !keys.enterMovesToNext || event.shiftKey || event.altKey)
1561
+ if ((event.defaultPrevented && !oneLine) || !keys.enterMovesToNext || event.shiftKey || event.altKey)
1451
1562
  return;
1452
- const from = event.target;
1453
- if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT')
1563
+ if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT' && !oneLine)
1454
1564
  return;
1455
1565
  if (from.closest('[data-type="one2many"]'))
1456
1566
  return;
@@ -1547,6 +1657,13 @@ export function mountViewer(host, given) {
1547
1657
  actionHost.say(result.message ?? '', result.reason === 'app' ? 'danger' : 'warning');
1548
1658
  }));
1549
1659
  render(form.getState());
1660
+ /** The form in another skin: each field's face put on again, and drawn again from the state. */
1661
+ function refaced() {
1662
+ for (const face of faces)
1663
+ face();
1664
+ if (mounted)
1665
+ renderNow();
1666
+ }
1550
1667
  mounted = true;
1551
1668
  // As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
1552
1669
  // only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
@@ -1573,13 +1690,15 @@ export function mountViewer(host, given) {
1573
1690
  element: root,
1574
1691
  setSkin(skin) {
1575
1692
  root.setAttribute('data-fd-skin', skin);
1693
+ refaced();
1576
1694
  },
1577
1695
  setTheme(next) {
1578
1696
  if (next)
1579
1697
  root.setAttribute('data-fd-theme', next);
1580
1698
  else
1581
1699
  root.removeAttribute('data-fd-theme');
1582
- root.setAttribute('data-fd-skin', skinFor(options.skin, next ?? undefined));
1700
+ root.setAttribute('data-fd-skin', skinFor(options.skin ?? page.look?.skin, next ?? undefined));
1701
+ refaced();
1583
1702
  },
1584
1703
  setTokens(next) {
1585
1704
  options.tokens = next ?? undefined;
@@ -1624,6 +1743,22 @@ export function mountViewer(host, given) {
1624
1743
  }
1625
1744
  /** The boxes a label can be shown in, as the words of an empty box. */
1626
1745
  const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
1746
+ /** Nothing in it: no value, empty words, nothing picked. */
1747
+ const isBlank = (value) => value === null || value === undefined || value === '' || (Array.isArray(value) && !value.length);
1748
+ /**
1749
+ * A label inside a sentence, as "Add story points" says it: in English and French its first letter small, unless the
1750
+ * word is a name such as "VAT"; German nouns, and Arabic, as written.
1751
+ */
1752
+ function inSentence(label, locale) {
1753
+ return (locale === 'en' || locale === 'fr') && /^\p{Lu}\p{Ll}/u.test(label) ? label[0].toLocaleLowerCase(locale) + label.slice(1) : label;
1754
+ }
1755
+ /**
1756
+ * What an empty field in the quiet look says, by its label, in the viewer's
1757
+ * words for `locale`: "Add story points", or for a question "Your answer".
1758
+ */
1759
+ export function quietHint(label, locale, labels = VIEWER_LABELS[locale]) {
1760
+ return /[?؟]\s*$/.test(label) ? labels.quietAnswer : labels.quietHint.replace('{label}', inSentence(label, locale));
1761
+ }
1627
1762
  /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
1628
1763
  const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
1629
1764
  /**