@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
package/src/lib/viewer.js CHANGED
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_LABELS = exports.VIEWER_LABELS = void 0;
4
4
  exports.mountViewer = mountViewer;
5
+ exports.quietHint = quietHint;
5
6
  exports.onelineRuns = onelineRuns;
6
7
  const core_1 = require("@fieldia/core");
7
8
  const widgets_1 = require("@fieldia/widgets");
@@ -118,7 +119,7 @@ function mountViewer(host, given) {
118
119
  return icon ? [icon, words] : [words];
119
120
  }
120
121
  const theme = options.theme ?? page.look?.theme;
121
- const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': (0, look_1.skinFor)(options.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
122
+ const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': (0, look_1.skinFor)(options.skin ?? page.look?.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
122
123
  (0, look_1.applyLook)(root, theme ? { ...page.look, theme } : page.look);
123
124
  (0, look_1.wearTokens)(root, options.tokens);
124
125
  const confirm = options.confirm ?? dialogConfirm;
@@ -153,6 +154,8 @@ function mountViewer(host, given) {
153
154
  // ---- the parts -------------------------------------------------------
154
155
  /** The fields that take the focus as the record opens, in the order they are drawn. */
155
156
  const focusers = [];
157
+ /** Each field's face, put on again when the form changes skin: quiet, or in its box. */
158
+ const faces = [];
156
159
  /** Folded sections, and how to open each: a problem inside one has to be seen. */
157
160
  const folds = new Map();
158
161
  /** Every part drawn again from the state of the page shown. */
@@ -176,8 +179,11 @@ function mountViewer(host, given) {
176
179
  const uid = (id) => ownUid(scope.prefix + id);
177
180
  /** A part's updater, handed the state of the part's own form. */
178
181
  const watch = (update) => sink.push(scope.root ? update : () => update(form.getState()));
179
- /** A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none. */
180
- function fieldItem(node, place) {
182
+ /**
183
+ * A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none.
184
+ * `around` says what the groups round it say of the quiet look: quiet or boxed, and why their fields are read-only.
185
+ */
186
+ function fieldItem(node, place, around = {}) {
181
187
  const def = page.fields[node.field];
182
188
  const id = uid(node.id);
183
189
  const labelsAt = (0, place_1.labelPlace)(node, def.type, place);
@@ -203,6 +209,39 @@ function mountViewer(host, given) {
203
209
  // Words chosen by a condition (placeholderWhen), else what the box showed as it was drawn.
204
210
  const drawnPlaceholder = textBox?.getAttribute('placeholder') ?? null;
205
211
  const chosenPlaceholder = node.placeholderWhen?.length ? textBox : null;
212
+ // 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
213
+ // wears the look (data-quiet) unless its widget keeps its own face.
214
+ const quietOwn = node.quiet ?? around.quiet;
215
+ const wears = (0, widgets_1.wearsQuiet)(def, node);
216
+ const quietPlace = () => quietOwn ?? root.getAttribute('data-fd-skin') === 'quiet';
217
+ // Empty and quiet, it says what to add, unless the page gives it words of its own: "Add story points", or for a
218
+ // question "Your answer". A field of several boxes, each with its own label (an address), says it in none of them.
219
+ const ownWords = !!node.placeholder || labelsAt === 'hidden' || def.type === 'json' || def.type === 'properties';
220
+ const hint = quietHint(labelText, locale, labels);
221
+ const restWords = () => (!ownWords && wrapper.hasAttribute('data-quiet') ? hint : drawnPlaceholder);
222
+ // Where no box can hold a placeholder (a choice's blank, a state's words), the page's own words come first.
223
+ const blankWords = node.placeholder || (labelsAt === 'hidden' ? labelText : hint);
224
+ const quietSelect = widget.element instanceof HTMLSelectElement ? widget.element.querySelector(':scope > option[value=""]') : null;
225
+ const richText = widget.element.matches('.fd-richtext') ? widget.element : widget.element.querySelector(':scope > .fd-richtext');
226
+ const stateWords = widget.element.querySelector(':scope > .fd-dot-label');
227
+ const face = () => {
228
+ const place = quietPlace();
229
+ const quiet = wears && place;
230
+ wrapper.toggleAttribute('data-in-quiet', place);
231
+ wrapper.toggleAttribute('data-quiet', quiet);
232
+ if (textBox && !chosenPlaceholder)
233
+ (0, dom_1.setAttr)(textBox, 'placeholder', restWords());
234
+ if (quietSelect)
235
+ quietSelect.textContent = quiet ? blankWords : '';
236
+ if (richText && !node.placeholder) {
237
+ (0, dom_1.setAttr)(richText, 'data-placeholder', quiet ? hint : null);
238
+ (0, dom_1.setAttr)(richText, 'aria-placeholder', quiet ? hint : null);
239
+ }
240
+ if (stateWords)
241
+ (0, dom_1.setAttr)(stateWords, 'data-hint', quiet ? blankWords : null);
242
+ };
243
+ faces.push(face);
244
+ face();
206
245
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
207
246
  // `for` stays: a custom field that puts the id on its own input is
208
247
  // labelled natively. Only a wrapper with a role (a radio group, say) may
@@ -212,7 +251,7 @@ function mountViewer(host, given) {
212
251
  named?.setAttribute('aria-labelledby', label.id);
213
252
  // Not a press on its (?) or the help bubble beside it: they show the help, and must not open a picker's list.
214
253
  label.addEventListener('click', (event) => {
215
- if (!event.target.closest?.('.fd-help-tip-wrap'))
254
+ if (!event.target.closest?.('.fd-help-tip-wrap, .fd-lock-wrap'))
216
255
  widget.focus();
217
256
  });
218
257
  }
@@ -229,6 +268,33 @@ function mountViewer(host, given) {
229
268
  label.append(tip.element);
230
269
  label.dir = 'auto';
231
270
  }
271
+ // 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,
272
+ // and in any skin when the page says why — why, it says in a bubble, as the (?) does, pointed at or focused. Made
273
+ // the first time it is needed: a field never locked has none.
274
+ const reason = node.readonlyReason ?? around.reason;
275
+ // Read-only by its nature — worked out from others, or always read-only where the page puts it — a value is shown,
276
+ // not locked: it has a lock only when the page says why.
277
+ const fixed = node.readonly === true || def.readonly === true || def.compute !== undefined;
278
+ let lock = null;
279
+ let lockMark = null;
280
+ let lockedNow = false;
281
+ const lockFor = () => {
282
+ if (lockMark)
283
+ return lockMark;
284
+ const icon = (0, widgets_1.drawIcon)(doc, 'lock', options.icons) ?? '';
285
+ lock = reason ? bubbleTip(`${id}-lock`, reason, fill(labels.locked, { label: labelText }), 'fd-lock', icon) : null;
286
+ lockMark = lock?.element ?? el('span', { class: 'fd-lock-wrap' }, el('span', { class: 'fd-lock', 'aria-hidden': 'true' }, icon));
287
+ if (labelsAt === 'hidden')
288
+ widget.element.after(lockMark);
289
+ else
290
+ label.insertBefore(lockMark, tip && labelsAt !== 'after' ? tip.element : null);
291
+ return lockMark;
292
+ };
293
+ // Pointing at a locked quiet value, or focusing it, says why too.
294
+ const point = (on) => lock?.point(on && lockedNow && wrapper.hasAttribute('data-in-quiet'));
295
+ if (reason)
296
+ for (const [event, on] of [['mouseenter', true], ['mouseleave', false], ['focusin', true], ['focusout', false]])
297
+ widget.element.addEventListener(event, () => point(on));
232
298
  // Not an alert of its own: a refused save is announced once, naming every field to look at.
233
299
  const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
234
300
  // A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
@@ -238,6 +304,9 @@ function mountViewer(host, given) {
238
304
  const asText = page.look?.readonlyShown === 'text' && (0, widgets_1.readsAsText)(def, node) ? (0, widgets_1.readText)({ document: doc, field: def, node, id, locale, labels: widgetLabels, dialogs }) : null;
239
305
  if (asText)
240
306
  widget.element.after(asText.element);
307
+ if (asText && reason)
308
+ for (const [event, on] of [['mouseenter', true], ['mouseleave', false]])
309
+ asText.element.addEventListener(event, () => point(on));
241
310
  if (widget.destroy)
242
311
  cleanups.push(() => widget.destroy?.());
243
312
  // An answer rule's warning waits until the person leaves the field: no advice
@@ -253,6 +322,22 @@ function mountViewer(host, given) {
253
322
  // (a deleted line's button), and that redraw must finish first.
254
323
  queueMicrotask(() => update(form.getState()));
255
324
  });
325
+ // Quiet, Escape puts back the value the field had as it was entered — once its own list or calendar, if open, has
326
+ // taken the key — and only then is it left for the dialog round it. Leaving keeps the change, for Save.
327
+ let entered;
328
+ wrapper.addEventListener('focusin', (event) => {
329
+ if (!wrapper.contains(event.relatedTarget))
330
+ entered = form.getState().values[node.field];
331
+ });
332
+ wrapper.addEventListener('keydown', (event) => {
333
+ if (event.key !== 'Escape' || event.defaultPrevented || !wrapper.hasAttribute('data-quiet'))
334
+ return;
335
+ if (JSON.stringify(form.getState().values[node.field] ?? null) === JSON.stringify(entered ?? null))
336
+ return;
337
+ event.preventDefault();
338
+ event.stopPropagation();
339
+ form.setValue(node.field, (entered ?? null));
340
+ });
256
341
  const toned = node.tones !== undefined || node.bold !== undefined;
257
342
  const update = (state) => {
258
343
  const shown = form.node(node.id);
@@ -265,7 +350,7 @@ function mountViewer(host, given) {
265
350
  wrapper.classList.toggle('fd-value-bold', look.bold);
266
351
  }
267
352
  if (chosenPlaceholder)
268
- (0, dom_1.setAttr)(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? drawnPlaceholder);
353
+ (0, dom_1.setAttr)(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? restWords());
269
354
  const message = state.errors[node.field];
270
355
  (0, dom_1.setHidden)(error, !message);
271
356
  (0, dom_1.setText)(error, message ?? '');
@@ -284,6 +369,16 @@ function mountViewer(host, given) {
284
369
  mark.toggleAttribute('hidden', !valid);
285
370
  }
286
371
  const readonly = shown.readonly || locked || scope.locked();
372
+ // 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.
373
+ const quiet = wrapper.hasAttribute('data-quiet');
374
+ lockedNow = readonly && !locked;
375
+ const showsLock = lockedNow && (!!reason || (wrapper.hasAttribute('data-in-quiet') && !fixed));
376
+ if (showsLock || lockMark)
377
+ (0, dom_1.setHidden)(lockFor(), !showsLock);
378
+ if (!lockedNow)
379
+ point(false);
380
+ (0, dom_1.setAttr)(wrapper, 'data-locked', quiet && showsLock ? '' : null);
381
+ (0, dom_1.setAttr)(wrapper, 'data-empty', quiet && isBlank(state.values[node.field]) ? '' : null);
287
382
  if (asText) {
288
383
  (0, dom_1.setHidden)(widget.element, readonly);
289
384
  (0, dom_1.setHidden)(asText.element, !readonly);
@@ -297,7 +392,7 @@ function mountViewer(host, given) {
297
392
  readonly,
298
393
  required: shown.required,
299
394
  invalid: !!message,
300
- describedBy: [help?.id ?? tip?.bubble.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
395
+ describedBy: [help?.id ?? tip?.bubble.id, lock && lockedNow ? lock.bubble.id : undefined, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
301
396
  });
302
397
  };
303
398
  watch(update);
@@ -305,8 +400,18 @@ function mountViewer(host, given) {
305
400
  }
306
401
  /** 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. */
307
402
  function helpTip(bubbleId, text, name) {
403
+ return bubbleTip(bubbleId, text, name, 'fd-help-tip', '?');
404
+ }
405
+ /**
406
+ * A small button with words in a bubble — a field's help behind its (?), why it is locked behind its lock — shown
407
+ * on hover, on focus, or kept open by a press, and gone with Escape. `point` shows it from elsewhere: its value pointed at.
408
+ */
409
+ function bubbleTip(bubbleId, text, name, kind, face) {
308
410
  const bubble = el('span', { class: 'fd-help-bubble', role: 'tooltip', id: bubbleId, hidden: '' }, text);
309
- const button = el('button', { type: 'button', class: 'fd-help-tip', 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, '?');
411
+ const button = el('button', { type: 'button', class: kind, 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, face);
412
+ // A lock's reason is said as it is reached; the help waits to be asked for.
413
+ if (kind === 'fd-lock')
414
+ button.setAttribute('aria-describedby', bubbleId);
310
415
  let kept = false;
311
416
  const show = (open) => {
312
417
  bubble.hidden = !open;
@@ -331,7 +436,7 @@ function mountViewer(host, given) {
331
436
  kept = false;
332
437
  show(false);
333
438
  });
334
- return { element: el('span', { class: 'fd-help-tip-wrap' }, button, bubble), bubble };
439
+ return { element: el('span', { class: kind === 'fd-lock' ? 'fd-lock-wrap' : 'fd-help-tip-wrap' }, button, bubble), bubble, point: (on) => show(on || kept) };
335
440
  }
336
441
  /** A bubble hangs from its (?): near the edge of a phone's screen it runs past it, so it is moved back in. */
337
442
  function keepOnScreen(bubble) {
@@ -430,7 +535,7 @@ function mountViewer(host, given) {
430
535
  const line = el('div', { class: 'fd-field fd-oneline', 'data-node': node.id, 'data-style': 'inline', 'data-labels': place.labels });
431
536
  spans(line, node.colspan);
432
537
  const row = el('div', { class: 'fd-oneline-row', role: 'group' }, ...onelineRuns(node.children).map((run) => {
433
- const parts = run.map((child) => item(child, { columns: 1, onPage: false, inline: true }));
538
+ const parts = run.map((child) => item(child, { columns: 1, onPage: false, inline: true, quiet: node.quiet ?? place.quiet, reason: node.readonlyReason ?? place.reason }));
434
539
  return parts.length === 1 ? parts[0] : el('span', { class: 'fd-oneline-pair' }, ...parts);
435
540
  }));
436
541
  if (node.title) {
@@ -440,6 +545,10 @@ function mountViewer(host, given) {
440
545
  }
441
546
  line.append(row);
442
547
  hideWhen(line, node.id);
548
+ // In a quiet place the line's label is quiet, as its fields' are.
549
+ const face = () => line.toggleAttribute('data-in-quiet', node.quiet ?? place.quiet ?? root.getAttribute('data-fd-skin') === 'quiet');
550
+ faces.push(face);
551
+ face();
443
552
  return line;
444
553
  }
445
554
  function sectionItem(node, place) {
@@ -521,7 +630,7 @@ function mountViewer(host, given) {
521
630
  sink = own;
522
631
  try {
523
632
  // A tab's parts sit where the tabs do: on the page, or in the box round them.
524
- panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
633
+ panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels, quiet: place.quiet, reason: place.reason }));
525
634
  }
526
635
  finally {
527
636
  sink = outer;
@@ -617,7 +726,7 @@ function mountViewer(host, given) {
617
726
  function partItem(node, place) {
618
727
  switch (node.type) {
619
728
  case 'field':
620
- return fieldItem(node, place.labels);
729
+ return fieldItem(node, place.labels, place);
621
730
  case 'button':
622
731
  return buttonItem(node);
623
732
  case 'text':
@@ -738,7 +847,7 @@ function mountViewer(host, given) {
738
847
  });
739
848
  const layout = saved.layout;
740
849
  // Its parts sit in the part's box: a card there draws none of its own.
741
- note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels }));
850
+ note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels, quiet: place.quiet }));
742
851
  box.removeAttribute('aria-busy');
743
852
  cleanups.push(inner.subscribe(renderNow));
744
853
  if (mounted)
@@ -1453,11 +1562,13 @@ function mountViewer(host, given) {
1453
1562
  void submitOrSave();
1454
1563
  return;
1455
1564
  }
1565
+ const from = event.target;
1566
+ // 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.
1567
+ const oneLine = from.getAttribute('aria-multiline') === 'false';
1456
1568
  // A plain Enter a field used itself, such as picking from an open list, is the field's.
1457
- if (event.defaultPrevented || !keys.enterMovesToNext || event.shiftKey || event.altKey)
1569
+ if ((event.defaultPrevented && !oneLine) || !keys.enterMovesToNext || event.shiftKey || event.altKey)
1458
1570
  return;
1459
- const from = event.target;
1460
- if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT')
1571
+ if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT' && !oneLine)
1461
1572
  return;
1462
1573
  if (from.closest('[data-type="one2many"]'))
1463
1574
  return;
@@ -1554,6 +1665,13 @@ function mountViewer(host, given) {
1554
1665
  actionHost.say(result.message ?? '', result.reason === 'app' ? 'danger' : 'warning');
1555
1666
  }));
1556
1667
  render(form.getState());
1668
+ /** The form in another skin: each field's face put on again, and drawn again from the state. */
1669
+ function refaced() {
1670
+ for (const face of faces)
1671
+ face();
1672
+ if (mounted)
1673
+ renderNow();
1674
+ }
1557
1675
  mounted = true;
1558
1676
  // As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
1559
1677
  // only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
@@ -1580,13 +1698,15 @@ function mountViewer(host, given) {
1580
1698
  element: root,
1581
1699
  setSkin(skin) {
1582
1700
  root.setAttribute('data-fd-skin', skin);
1701
+ refaced();
1583
1702
  },
1584
1703
  setTheme(next) {
1585
1704
  if (next)
1586
1705
  root.setAttribute('data-fd-theme', next);
1587
1706
  else
1588
1707
  root.removeAttribute('data-fd-theme');
1589
- root.setAttribute('data-fd-skin', (0, look_1.skinFor)(options.skin, next ?? undefined));
1708
+ root.setAttribute('data-fd-skin', (0, look_1.skinFor)(options.skin ?? page.look?.skin, next ?? undefined));
1709
+ refaced();
1590
1710
  },
1591
1711
  setTokens(next) {
1592
1712
  options.tokens = next ?? undefined;
@@ -1631,6 +1751,22 @@ function mountViewer(host, given) {
1631
1751
  }
1632
1752
  /** The boxes a label can be shown in, as the words of an empty box. */
1633
1753
  const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
1754
+ /** Nothing in it: no value, empty words, nothing picked. */
1755
+ const isBlank = (value) => value === null || value === undefined || value === '' || (Array.isArray(value) && !value.length);
1756
+ /**
1757
+ * A label inside a sentence, as "Add story points" says it: in English and French its first letter small, unless the
1758
+ * word is a name such as "VAT"; German nouns, and Arabic, as written.
1759
+ */
1760
+ function inSentence(label, locale) {
1761
+ return (locale === 'en' || locale === 'fr') && /^\p{Lu}\p{Ll}/u.test(label) ? label[0].toLocaleLowerCase(locale) + label.slice(1) : label;
1762
+ }
1763
+ /**
1764
+ * What an empty field in the quiet look says, by its label, in the viewer's
1765
+ * words for `locale`: "Add story points", or for a question "Your answer".
1766
+ */
1767
+ function quietHint(label, locale, labels = labels_1.VIEWER_LABELS[locale]) {
1768
+ return /[?؟]\s*$/.test(label) ? labels.quietAnswer : labels.quietHint.replace('{label}', inSentence(label, locale));
1769
+ }
1634
1770
  /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
1635
1771
  const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
1636
1772
  /**