@fieldia/viewer 0.9.1 → 0.10.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 (98) hide show
  1. package/README.md +20 -1
  2. package/bundle/fieldia.ar.js +3 -0
  3. package/bundle/fieldia.ar.js.map +7 -0
  4. package/bundle/fieldia.de.js +3 -0
  5. package/bundle/fieldia.de.js.map +7 -0
  6. package/bundle/fieldia.fr.js +3 -0
  7. package/bundle/fieldia.fr.js.map +7 -0
  8. package/bundle/fieldia.js +4 -4
  9. package/bundle/fieldia.js.map +4 -4
  10. package/package.json +6 -3
  11. package/src/index.d.mts +4 -0
  12. package/src/index.d.ts +4 -0
  13. package/src/index.js +12 -0
  14. package/src/index.js.map +1 -1
  15. package/src/index.mjs +4 -0
  16. package/src/index.mjs.map +1 -1
  17. package/src/lib/add-language.d.mts +20 -0
  18. package/src/lib/add-language.d.ts +20 -0
  19. package/src/lib/add-language.js +19 -0
  20. package/src/lib/add-language.js.map +1 -0
  21. package/src/lib/add-language.mjs +16 -0
  22. package/src/lib/add-language.mjs.map +1 -0
  23. package/src/lib/dialog.d.mts +7 -2
  24. package/src/lib/dialog.d.ts +7 -2
  25. package/src/lib/dialog.js +16 -21
  26. package/src/lib/dialog.js.map +1 -1
  27. package/src/lib/dialog.mjs +16 -21
  28. package/src/lib/dialog.mjs.map +1 -1
  29. package/src/lib/dom.d.mts +10 -0
  30. package/src/lib/dom.d.ts +10 -0
  31. package/src/lib/dom.js +26 -0
  32. package/src/lib/dom.js.map +1 -1
  33. package/src/lib/dom.mjs +23 -1
  34. package/src/lib/dom.mjs.map +1 -1
  35. package/src/lib/focus-trap.d.mts +2 -0
  36. package/src/lib/focus-trap.d.ts +2 -0
  37. package/src/lib/focus-trap.js +7 -0
  38. package/src/lib/focus-trap.js.map +1 -0
  39. package/src/lib/focus-trap.mjs +3 -0
  40. package/src/lib/focus-trap.mjs.map +1 -0
  41. package/src/lib/labels.d.mts +13 -0
  42. package/src/lib/labels.d.ts +13 -0
  43. package/src/lib/labels.js +103 -277
  44. package/src/lib/labels.js.map +1 -1
  45. package/src/lib/labels.mjs +102 -277
  46. package/src/lib/labels.mjs.map +1 -1
  47. package/src/lib/list-styles.d.mts +1 -1
  48. package/src/lib/list-styles.d.ts +1 -1
  49. package/src/lib/list-styles.js +3 -2
  50. package/src/lib/list-styles.js.map +1 -1
  51. package/src/lib/list-styles.mjs +3 -2
  52. package/src/lib/list-styles.mjs.map +1 -1
  53. package/src/lib/locales/all.d.mts +8 -0
  54. package/src/lib/locales/all.d.ts +8 -0
  55. package/src/lib/locales/all.js +13 -0
  56. package/src/lib/locales/all.js.map +1 -0
  57. package/src/lib/locales/all.mjs +10 -0
  58. package/src/lib/locales/all.mjs.map +1 -0
  59. package/src/lib/locales/ar.d.mts +3 -0
  60. package/src/lib/locales/ar.d.ts +3 -0
  61. package/src/lib/locales/ar.js +74 -0
  62. package/src/lib/locales/ar.js.map +1 -0
  63. package/src/lib/locales/ar.mjs +71 -0
  64. package/src/lib/locales/ar.mjs.map +1 -0
  65. package/src/lib/locales/de.d.mts +3 -0
  66. package/src/lib/locales/de.d.ts +3 -0
  67. package/src/lib/locales/de.js +74 -0
  68. package/src/lib/locales/de.js.map +1 -0
  69. package/src/lib/locales/de.mjs +71 -0
  70. package/src/lib/locales/de.mjs.map +1 -0
  71. package/src/lib/locales/fr.d.mts +3 -0
  72. package/src/lib/locales/fr.d.ts +3 -0
  73. package/src/lib/locales/fr.js +74 -0
  74. package/src/lib/locales/fr.js.map +1 -0
  75. package/src/lib/locales/fr.mjs +71 -0
  76. package/src/lib/locales/fr.mjs.map +1 -0
  77. package/src/lib/look.d.mts +21 -0
  78. package/src/lib/look.d.ts +21 -0
  79. package/src/lib/look.js +84 -0
  80. package/src/lib/look.js.map +1 -0
  81. package/src/lib/look.mjs +79 -0
  82. package/src/lib/look.mjs.map +1 -0
  83. package/src/lib/place.d.mts +35 -0
  84. package/src/lib/place.d.ts +35 -0
  85. package/src/lib/place.js +37 -0
  86. package/src/lib/place.js.map +1 -0
  87. package/src/lib/place.mjs +33 -0
  88. package/src/lib/place.mjs.map +1 -0
  89. package/src/lib/related.js +2 -1
  90. package/src/lib/related.js.map +1 -1
  91. package/src/lib/related.mjs +2 -1
  92. package/src/lib/related.mjs.map +1 -1
  93. package/src/lib/viewer.d.mts +10 -2
  94. package/src/lib/viewer.d.ts +10 -2
  95. package/src/lib/viewer.js +160 -57
  96. package/src/lib/viewer.js.map +1 -1
  97. package/src/lib/viewer.mjs +162 -59
  98. package/src/lib/viewer.mjs.map +1 -1
@@ -1,8 +1,12 @@
1
- import { createForm, checkPage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
1
+ import { createForm, checkPage, isRightToLeft, localizePage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
2
2
  import { browserPreferences, createWidget, drawIcon, installStyles, WIDGET_LABELS } from '@fieldia/widgets';
3
3
  import { pageDialogs } from './related.mjs';
4
4
  import { listView } from './list.mjs';
5
- import { VIEWER_LABELS } from './labels.mjs';
5
+ import { applyLook } from './look.mjs';
6
+ import { labelPlace, planSection } from './place.mjs';
7
+ import { setAttr, setHidden, setText } from './dom.mjs';
8
+ import { ownLocale, VIEWER_LABELS } from './labels.mjs';
9
+ import { keepTabIn } from './focus-trap.mjs';
6
10
  export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
7
11
  let mounts = 0;
8
12
  /**
@@ -17,15 +21,18 @@ export function mountViewer(host, options) {
17
21
  if (!checked.ok) {
18
22
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
19
23
  }
20
- const page = options.translate ? translatePage(checked.page, options.translate) : checked.page;
21
- const locale = options.locale ?? 'en';
24
+ // The page's own words in its language first; the app's translator over what is left.
25
+ const tag = options.locale ?? checked.page.language;
26
+ const localized = tag ? localizePage(checked.page, tag) : checked.page;
27
+ const page = options.translate ? translatePage(localized, options.translate) : localized;
28
+ const locale = ownLocale(tag);
22
29
  const preferences = options.preferences ?? browserPreferences();
23
30
  const dialogs = pageDialogs(options);
24
31
  const ownsForm = !options.form;
25
32
  const form = options.form ?? createForm({ ...options, page, messages: options.messages ?? MESSAGES[locale] });
26
33
  const labels = { ...VIEWER_LABELS[locale], ...options.labels };
27
34
  const widgetLabels = WIDGET_LABELS[locale];
28
- const dir = options.dir ?? (locale === 'ar' ? 'rtl' : undefined);
35
+ const dir = options.dir ?? (tag && isRightToLeft(tag) ? 'rtl' : undefined);
29
36
  const prefix = `fd${++mounts}`;
30
37
  const updaters = [];
31
38
  const cleanups = [];
@@ -40,21 +47,30 @@ export function mountViewer(host, options) {
40
47
  node.append(...children);
41
48
  return node;
42
49
  }
43
- /** Words with the icon a page names before them, when there is such an icon. */
50
+ /**
51
+ * Words with the icon a page names before them, when there is such an icon.
52
+ * The words sit in a <bdi>: left as written on a page of the other direction,
53
+ * they still run their own way, the icon still before them.
54
+ */
44
55
  function withIcon(name, text) {
45
56
  const icon = drawIcon(doc, name, options.icons);
46
- return icon ? [icon, text] : [text];
57
+ const words = el('bdi', {}, text);
58
+ return icon ? [icon, words] : [words];
47
59
  }
48
- const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: options.locale, 'data-max-width': page.maxWidth });
60
+ const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: tag, 'data-max-width': page.maxWidth });
61
+ applyLook(root, page.look);
49
62
  const confirm = options.confirm ?? dialogConfirm;
50
63
  // ---- the parts -------------------------------------------------------
51
- function fieldItem(node) {
64
+ /** A field with its label, help and messages. `labels` is where labels sit where it is put; the title's own fields take none. */
65
+ function fieldItem(node, labels) {
52
66
  const def = page.fields[node.field];
53
67
  const id = uid(node.id);
54
- const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-type': def.type });
68
+ const labelsAt = labelPlace(node, def.type, labels);
69
+ const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-type': def.type, 'data-labels': labelsAt });
55
70
  if (node.colspan)
56
71
  wrapper.style.setProperty('--fd-span', String(node.colspan));
57
- const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, node.label ?? def.label);
72
+ const labelText = node.label ?? def.label;
73
+ const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
58
74
  // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
59
75
  const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? drawIcon(doc, 'check') : null;
60
76
  if (mark) {
@@ -62,33 +78,56 @@ export function mountViewer(host, options) {
62
78
  label.append(mark);
63
79
  }
64
80
  const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
81
+ // A label kept out of sight still names the box; the empty box shows it instead, unless the page gives it words of its own.
82
+ if (labelsAt === 'hidden' && !node.placeholder) {
83
+ const box = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
84
+ box?.setAttribute('placeholder', labelText);
85
+ }
65
86
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
66
87
  // `for` stays: a custom field that puts the id on its own input is
67
88
  // labelled natively. Only a wrapper with a role (a radio group, say) may
68
89
  // carry a name; ARIA forbids naming a plain div or span.
69
- if (widget.element.getAttribute('role'))
70
- widget.element.setAttribute('aria-labelledby', label.id);
90
+ // A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
91
+ const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
92
+ named?.setAttribute('aria-labelledby', label.id);
71
93
  label.addEventListener('click', () => widget.focus());
72
94
  }
73
95
  const helpText = node.help ?? def.help;
74
96
  const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
75
97
  // Not an alert of its own: a refused save is announced once, naming every field to look at.
76
98
  const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
77
- // A warning from the data source's onchange, beside the field whose change brought it.
78
- const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
79
- wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
99
+ // A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
100
+ const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
101
+ wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
80
102
  if (widget.destroy)
81
103
  cleanups.push(() => widget.destroy?.());
82
- updaters.push((state) => {
104
+ // An answer rule's warning waits until the person leaves the field: no advice
105
+ // mid-word. One already shown stays while they put it right, and goes once they have.
106
+ let typing = false;
107
+ let advice = '';
108
+ wrapper.addEventListener('focusin', () => (typing = true));
109
+ wrapper.addEventListener('focusout', (event) => {
110
+ if (wrapper.contains(event.relatedTarget))
111
+ return;
112
+ typing = false;
113
+ // Not at once: focus also leaves when a redraw removes the focused part
114
+ // (a deleted line's button), and that redraw must finish first.
115
+ queueMicrotask(() => update(form.getState()));
116
+ });
117
+ const update = (state) => {
83
118
  const shown = form.node(node.id);
84
- wrapper.hidden = shown.invisible;
119
+ setHidden(wrapper, shown.invisible);
85
120
  wrapper.classList.toggle('fd-required', shown.required);
86
121
  const message = state.errors[node.field];
87
- error.hidden = !message;
88
- error.textContent = message ?? '';
89
- const warned = state.warning && state.warningField === node.field ? state.warning : '';
90
- warning.hidden = !warned;
91
- warning.textContent = warned;
122
+ setHidden(error, !message);
123
+ setText(error, message ?? '');
124
+ const now = state.warnings[node.field] ?? '';
125
+ if (!typing || !now)
126
+ advice = now;
127
+ // An error shown says enough: the rule's warning gives way to it.
128
+ const warned = [message ? '' : advice, state.warning && state.warningField === node.field ? state.warning : ''].filter(Boolean).join(' ');
129
+ setHidden(warning, !warned);
130
+ setText(warning, warned);
92
131
  if (mark) {
93
132
  const value = state.values[node.field];
94
133
  const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
@@ -102,18 +141,18 @@ export function mountViewer(host, options) {
102
141
  readonly: shown.readonly || locked,
103
142
  required: shown.required,
104
143
  invalid: !!message,
105
- describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
144
+ describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
106
145
  });
107
- });
146
+ };
147
+ updaters.push(update);
108
148
  return wrapper;
109
149
  }
110
150
  function hideWhen(element, id) {
111
- updaters.push(() => {
112
- element.hidden = form.node(id).invisible;
113
- });
151
+ updaters.push(() => setHidden(element, form.node(id).invisible));
114
152
  }
115
153
  function buttonItem(node) {
116
154
  const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
155
+ spans(button, node.colspan);
117
156
  button.addEventListener('click', async () => {
118
157
  if (node.confirm && !(await confirm(node.confirm)))
119
158
  return;
@@ -125,6 +164,7 @@ export function mountViewer(host, options) {
125
164
  function textItem(node) {
126
165
  const style = node.style ?? 'paragraph';
127
166
  const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
167
+ spans(element, node.colspan);
128
168
  hideWhen(element, node.id);
129
169
  return element;
130
170
  }
@@ -136,28 +176,52 @@ export function mountViewer(host, options) {
136
176
  hideWhen(element, node.id);
137
177
  return element;
138
178
  }
139
- function grid(children, columns = 1) {
179
+ /** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
180
+ function spans(element, colspan) {
181
+ if (colspan)
182
+ element.style.setProperty('--fd-span', String(colspan));
183
+ }
184
+ /**
185
+ * A grid of parts. With `columns` null, it has none of its own: it lays its
186
+ * parts on the columns of the grid round it (an arrangement on its tracks).
187
+ */
188
+ function grid(children, columns, place) {
140
189
  const box = el('div', { class: 'fd-grid' });
141
- box.style.setProperty('--fd-columns', String(wideColumns(columns)));
142
- // Counts given for the narrower widths replace the skin's own stacking there.
143
- if (typeof columns === 'object') {
144
- for (const width of ['medium', 'narrow']) {
145
- const count = columns[width];
146
- if (count === undefined)
147
- continue;
148
- box.setAttribute(`data-columns-${width}`, String(count));
149
- box.style.setProperty(`--fd-columns-${width}`, String(count));
190
+ if (columns !== null) {
191
+ box.style.setProperty('--fd-columns', String(wideColumns(columns)));
192
+ // Counts given for the narrower widths replace the skin's own stacking there.
193
+ if (typeof columns === 'object') {
194
+ for (const width of ['medium', 'narrow']) {
195
+ const count = columns[width];
196
+ if (count === undefined)
197
+ continue;
198
+ box.setAttribute(`data-columns-${width}`, String(count));
199
+ box.style.setProperty(`--fd-columns-${width}`, String(count));
200
+ }
150
201
  }
151
202
  }
152
- box.append(...children.map(item));
203
+ box.append(...children.map((child) => item(child, place)));
153
204
  return box;
154
205
  }
206
+ /** Where the parts of a page, a tab or a step sit: one column, the page's labels. */
207
+ const top = (onPage) => ({ columns: 1, onPage, labels: page.look?.labels });
155
208
  /** Folded sections, and how to open each: a problem inside one has to be seen. */
156
209
  const folds = new Map();
157
- function sectionItem(node) {
158
- const section = el('fieldset', { class: 'fd-section', 'data-node': node.id });
210
+ function sectionItem(node, place) {
211
+ const plan = planSection(node, place);
212
+ // An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
213
+ const section = el(plan.arrangement ? 'div' : 'fieldset', {
214
+ class: 'fd-section',
215
+ 'data-node': node.id,
216
+ 'data-style': plan.style,
217
+ 'data-place': plan.at,
218
+ 'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
219
+ });
220
+ spans(section, node.colspan);
221
+ if (node.labelWidth)
222
+ section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
159
223
  const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
160
- const content = grid(node.children, node.columns);
224
+ const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
161
225
  if (node.title && node.collapsible) {
162
226
  content.id = uid(`${node.id}-content`);
163
227
  const toggle = el('button', { type: 'button', class: 'fd-section-toggle', 'aria-controls': content.id }, el('span', { class: 'fd-section-chevron', 'aria-hidden': 'true' }), ...withIcon(node.icon, node.title));
@@ -184,8 +248,9 @@ export function mountViewer(host, options) {
184
248
  hideWhen(section, node.id);
185
249
  return section;
186
250
  }
187
- function tabsItem(node) {
251
+ function tabsItem(node, place) {
188
252
  const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
253
+ spans(box, node.colspan);
189
254
  const list = el('div', { class: 'fd-tablist', role: 'tablist' });
190
255
  // The tab someone picked. Until they pick, the first visible tab is open —
191
256
  // a tab hidden while the record loads must not leave its neighbour open.
@@ -195,7 +260,8 @@ export function mountViewer(host, options) {
195
260
  const tabId = uid(`${tab.id}-tab`);
196
261
  const panelId = uid(`${tab.id}-panel`);
197
262
  const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, ...withIcon(tab.icon, tab.label));
198
- const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children));
263
+ // A tab's parts sit where the tabs do: on the page, or in the box round them.
264
+ const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
199
265
  button.addEventListener('click', () => {
200
266
  picked = active = tab.id;
201
267
  render(form.getState());
@@ -235,10 +301,10 @@ export function mountViewer(host, options) {
235
301
  });
236
302
  return box;
237
303
  }
238
- function item(node) {
304
+ function item(node, place) {
239
305
  switch (node.type) {
240
306
  case 'field':
241
- return fieldItem(node);
307
+ return fieldItem(node, place.labels);
242
308
  case 'button':
243
309
  return buttonItem(node);
244
310
  case 'text':
@@ -246,11 +312,35 @@ export function mountViewer(host, options) {
246
312
  case 'slot':
247
313
  return slotItem(node);
248
314
  case 'section':
249
- return sectionItem(node);
315
+ return sectionItem(node, place);
250
316
  case 'tabs':
251
- return tabsItem(node);
317
+ return tabsItem(node, place);
318
+ case 'divider':
319
+ case 'spacer':
320
+ case 'image':
321
+ return blockItem(node);
252
322
  }
253
323
  }
324
+ /** A line across the row, empty room, or a picture: shown and hidden by its rule like any part. */
325
+ function blockItem(node) {
326
+ const element = node.type === 'divider' ? el('hr', { class: 'fd-divider', 'data-node': node.id })
327
+ : node.type === 'spacer' ? el('div', { class: 'fd-block fd-spacer', 'aria-hidden': 'true', 'data-node': node.id })
328
+ : figure(node);
329
+ if (node.type !== 'divider' && node.colspan)
330
+ element.style.setProperty('--fd-span', String(node.colspan));
331
+ hideWhen(element, node.id);
332
+ return element;
333
+ }
334
+ /** A picture as wide and where its page says, opening its link in a new tab — a web or mail address only — its caption under it. */
335
+ function figure(node) {
336
+ const image = el('img', { class: 'fd-image', src: node.src, alt: node.alt, loading: 'lazy' });
337
+ const href = node.href && /^(https?:\/\/|mailto:)/i.test(node.href) ? node.href : undefined;
338
+ const element = el('figure', { class: 'fd-block fd-figure', 'data-node': node.id, 'data-align': node.align }, href ? el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, image) : image, ...(node.caption ? [el('figcaption', { class: 'fd-caption' }, node.caption)] : []));
339
+ const width = typeof node.width === 'number' ? `${node.width}px` : node.width && { small: '160px', medium: '320px', large: '480px', full: '100%' }[node.width];
340
+ if (width)
341
+ element.style.setProperty('--fd-image-width', width);
342
+ return element;
343
+ }
254
344
  // ---- shared chrome ----------------------------------------------------------
255
345
  /** The whole form locked: every field read-only, no Save; buttons still run. */
256
346
  let locked = options.readonly === true;
@@ -310,8 +400,10 @@ export function mountViewer(host, options) {
310
400
  : state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
311
401
  : state.status === 'error' ? state.error ?? ''
312
402
  : '';
313
- statusText.textContent = text;
314
- retry.hidden = !(state.status === 'error' && problem?.kind === 'other');
403
+ // A problem is said once, at once, by the announcer (or the banner, or a dialog): the status shows it and keeps quiet.
404
+ setAttr(statusText, 'aria-live', state.status === 'error' && problem ? 'off' : 'polite');
405
+ setText(statusText, text);
406
+ setHidden(retry, !(state.status === 'error' && problem?.kind === 'other'));
315
407
  statusText.classList.toggle('fd-status-error', state.status === 'error');
316
408
  statusText.classList.toggle('fd-status-saved', state.status === 'saved');
317
409
  // A toast shows while there is something to say, and lets "Saved" go after a moment.
@@ -356,7 +448,7 @@ export function mountViewer(host, options) {
356
448
  discardDraft.addEventListener('click', () => form.discardDraft());
357
449
  draft.append(draftText, restore, discardDraft);
358
450
  updaters.push((state) => {
359
- draft.hidden = !state.draft;
451
+ setHidden(draft, !state.draft);
360
452
  if (state.draft)
361
453
  draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
362
454
  });
@@ -408,7 +500,7 @@ export function mountViewer(host, options) {
408
500
  }
409
501
  // ---- the four page layouts ---------------------------------------------------
410
502
  function sectionsLayout(node) {
411
- const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map(item));
503
+ const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
412
504
  const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
413
505
  const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
414
506
  if (page.data.kind === 'record') {
@@ -418,12 +510,12 @@ export function mountViewer(host, options) {
418
510
  actions.insertBefore(discard, action);
419
511
  updaters.push((state) => {
420
512
  const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
421
- action.hidden = !dirty;
422
- discard.hidden = !dirty;
513
+ setHidden(action, !dirty);
514
+ setHidden(discard, !dirty);
423
515
  });
424
516
  }
425
517
  else
426
- updaters.push(() => (action.hidden = locked));
518
+ updaters.push(() => setHidden(action, locked));
427
519
  if (editSwitch)
428
520
  actions.prepend(editSwitch);
429
521
  if (options.showActions === false)
@@ -461,7 +553,8 @@ export function mountViewer(host, options) {
461
553
  panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, ...withIcon(step.icon, step.label)));
462
554
  if (step.description)
463
555
  panel.append(el('p', { class: 'fd-section-description' }, step.description));
464
- panel.append(grid(step.children));
556
+ // The step is the box: a card in it draws none of its own.
557
+ panel.append(grid(step.children, 1, top(false)));
465
558
  box.append(panel);
466
559
  return { step, panel };
467
560
  });
@@ -604,12 +697,12 @@ export function mountViewer(host, options) {
604
697
  if (node.title) {
605
698
  const title = el('div', { class: 'fd-title' });
606
699
  if (node.title.above?.length)
607
- title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map(fieldItem)));
700
+ title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
608
701
  title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
609
702
  if (node.title.subtitleField)
610
703
  title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
611
704
  if (node.title.below?.length)
612
- title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map(fieldItem)));
705
+ title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map((part) => fieldItem(part))));
613
706
  const row = el('div', { class: 'fd-title-row' }, title);
614
707
  if (node.title.avatarField) {
615
708
  const avatar = fieldItem({ type: 'field', id: '#avatar', field: node.title.avatarField });
@@ -620,7 +713,7 @@ export function mountViewer(host, options) {
620
713
  }
621
714
  if (node.statusbar && underTitle)
622
715
  card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
623
- card.append(grid(node.children));
716
+ card.append(grid(node.children, 1, top(false)));
624
717
  if (atFoot && options.showActions !== false) {
625
718
  card.append(foot);
626
719
  updaters.push((state) => {
@@ -763,8 +856,14 @@ export function mountViewer(host, options) {
763
856
  const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
764
857
  const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, ...(withCancel ? [cancel, ok] : [ok])));
765
858
  const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
859
+ const doc = root.ownerDocument;
860
+ const opener = doc.activeElement;
766
861
  const close = (answer) => {
767
862
  backdrop.remove();
863
+ // Back to what had focus when it asked; or, when that let go of it — Save hides while it saves — to Save, shown again.
864
+ const shown = (e) => !!e && e !== doc.body && e.isConnected && !e.closest('[hidden]');
865
+ const back = shown(opener) ? opener : root.querySelector('button[type="submit"]:not([hidden])');
866
+ back?.focus();
768
867
  resolve(answer);
769
868
  };
770
869
  cancel.addEventListener('click', () => close(false));
@@ -772,6 +871,8 @@ export function mountViewer(host, options) {
772
871
  backdrop.addEventListener('keydown', (event) => {
773
872
  if (event.key === 'Escape')
774
873
  close(false);
874
+ else
875
+ keepTabIn(dialog, event);
775
876
  });
776
877
  root.append(backdrop);
777
878
  ok.focus();
@@ -821,6 +922,8 @@ export function mountViewer(host, options) {
821
922
  },
822
923
  };
823
924
  }
925
+ /** The boxes a label can be shown in, as the words of an empty box. */
926
+ const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
824
927
  /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
825
928
  const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
826
929
  //# sourceMappingURL=viewer.mjs.map