@fieldia/viewer 0.9.2 → 0.11.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 (118) hide show
  1. package/README.md +145 -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 +35 -3
  24. package/src/lib/dialog.d.ts +35 -3
  25. package/src/lib/dialog.js +109 -47
  26. package/src/lib/dialog.js.map +1 -1
  27. package/src/lib/dialog.mjs +107 -47
  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 +27 -0
  42. package/src/lib/labels.d.ts +27 -0
  43. package/src/lib/labels.js +109 -277
  44. package/src/lib/labels.js.map +1 -1
  45. package/src/lib/labels.mjs +108 -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/list.d.mts +3 -2
  54. package/src/lib/list.d.ts +3 -2
  55. package/src/lib/list.js +5 -5
  56. package/src/lib/list.js.map +1 -1
  57. package/src/lib/list.mjs +5 -5
  58. package/src/lib/list.mjs.map +1 -1
  59. package/src/lib/locales/all.d.mts +8 -0
  60. package/src/lib/locales/all.d.ts +8 -0
  61. package/src/lib/locales/all.js +13 -0
  62. package/src/lib/locales/all.js.map +1 -0
  63. package/src/lib/locales/all.mjs +10 -0
  64. package/src/lib/locales/all.mjs.map +1 -0
  65. package/src/lib/locales/ar.d.mts +3 -0
  66. package/src/lib/locales/ar.d.ts +3 -0
  67. package/src/lib/locales/ar.js +80 -0
  68. package/src/lib/locales/ar.js.map +1 -0
  69. package/src/lib/locales/ar.mjs +77 -0
  70. package/src/lib/locales/ar.mjs.map +1 -0
  71. package/src/lib/locales/de.d.mts +3 -0
  72. package/src/lib/locales/de.d.ts +3 -0
  73. package/src/lib/locales/de.js +80 -0
  74. package/src/lib/locales/de.js.map +1 -0
  75. package/src/lib/locales/de.mjs +77 -0
  76. package/src/lib/locales/de.mjs.map +1 -0
  77. package/src/lib/locales/fr.d.mts +3 -0
  78. package/src/lib/locales/fr.d.ts +3 -0
  79. package/src/lib/locales/fr.js +80 -0
  80. package/src/lib/locales/fr.js.map +1 -0
  81. package/src/lib/locales/fr.mjs +77 -0
  82. package/src/lib/locales/fr.mjs.map +1 -0
  83. package/src/lib/look.d.mts +25 -0
  84. package/src/lib/look.d.ts +25 -0
  85. package/src/lib/look.js +141 -0
  86. package/src/lib/look.js.map +1 -0
  87. package/src/lib/look.mjs +136 -0
  88. package/src/lib/look.mjs.map +1 -0
  89. package/src/lib/open.d.mts +24 -0
  90. package/src/lib/open.d.ts +24 -0
  91. package/src/lib/open.js +81 -0
  92. package/src/lib/open.js.map +1 -0
  93. package/src/lib/open.mjs +78 -0
  94. package/src/lib/open.mjs.map +1 -0
  95. package/src/lib/place.d.mts +35 -0
  96. package/src/lib/place.d.ts +35 -0
  97. package/src/lib/place.js +37 -0
  98. package/src/lib/place.js.map +1 -0
  99. package/src/lib/place.mjs +33 -0
  100. package/src/lib/place.mjs.map +1 -0
  101. package/src/lib/related.d.mts +19 -0
  102. package/src/lib/related.d.ts +19 -0
  103. package/src/lib/related.js +17 -3
  104. package/src/lib/related.js.map +1 -1
  105. package/src/lib/related.mjs +16 -3
  106. package/src/lib/related.mjs.map +1 -1
  107. package/src/lib/say.d.mts +9 -0
  108. package/src/lib/say.d.ts +9 -0
  109. package/src/lib/say.js +45 -0
  110. package/src/lib/say.js.map +1 -0
  111. package/src/lib/say.mjs +42 -0
  112. package/src/lib/say.mjs.map +1 -0
  113. package/src/lib/viewer.d.mts +43 -7
  114. package/src/lib/viewer.d.ts +43 -7
  115. package/src/lib/viewer.js +513 -225
  116. package/src/lib/viewer.js.map +1 -1
  117. package/src/lib/viewer.mjs +516 -228
  118. package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.js CHANGED
@@ -6,7 +6,13 @@ const core_1 = require("@fieldia/core");
6
6
  const widgets_1 = require("@fieldia/widgets");
7
7
  const related_1 = require("./related");
8
8
  const list_1 = require("./list");
9
+ const look_1 = require("./look");
10
+ const place_1 = require("./place");
11
+ const dom_1 = require("./dom");
12
+ const open_1 = require("./open");
13
+ const say_1 = require("./say");
9
14
  const labels_1 = require("./labels");
15
+ const focus_trap_1 = require("./focus-trap");
10
16
  var labels_2 = require("./labels");
11
17
  Object.defineProperty(exports, "VIEWER_LABELS", { enumerable: true, get: function () { return labels_2.VIEWER_LABELS; } });
12
18
  Object.defineProperty(exports, "DEFAULT_LABELS", { enumerable: true, get: function () { return labels_2.DEFAULT_LABELS; } });
@@ -23,19 +29,44 @@ function mountViewer(host, options) {
23
29
  if (!checked.ok) {
24
30
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
25
31
  }
26
- const page = options.translate ? (0, core_1.translatePage)(checked.page, options.translate) : checked.page;
27
- const locale = options.locale ?? 'en';
32
+ // The page's own words in its language first; the app's translator over what is left.
33
+ const tag = options.locale ?? checked.page.language;
34
+ const localized = tag ? (0, core_1.localizePage)(checked.page, tag) : checked.page;
35
+ const page = options.translate ? (0, core_1.translatePage)(localized, options.translate) : localized;
36
+ /** Another page as this one is shown: in its language, through the app's translator. */
37
+ const shownAs = (other) => {
38
+ const own = tag ? (0, core_1.localizePage)(other, tag) : other;
39
+ return options.translate ? (0, core_1.translatePage)(own, options.translate) : own;
40
+ };
41
+ const locale = (0, labels_1.ownLocale)(tag);
28
42
  const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
29
43
  const dialogs = (0, related_1.pageDialogs)(options);
30
44
  const ownsForm = !options.form;
31
- const form = options.form ?? (0, core_1.createForm)({ ...options, page, messages: options.messages ?? core_1.MESSAGES[locale] });
32
45
  const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
46
+ /**
47
+ * What the page's steps ask of the screen: a page opened, words said, a
48
+ * question asked, a tab shown — the viewer's own, under the app's. A form
49
+ * made elsewhere (`form`) keeps the host it was made with.
50
+ */
51
+ const actionHost = {
52
+ open: (request) => (0, open_1.openPage)({ options, place: host, root, title: (other) => shownAs(other).title || other.id, back: labels.back, missing: (id) => fill(labels.pageMissing, { page: id }) }, request),
53
+ say: (message, tone) => say(message, tone),
54
+ ask: (message) => confirm(message),
55
+ show: (target) => {
56
+ const show = tabs.get(target);
57
+ if (!show?.())
58
+ throw new Error(`No tab "${target}" can be shown here`);
59
+ },
60
+ ...options.host,
61
+ };
62
+ const form = options.form ?? (0, core_1.createForm)({ ...options, page, host: actionHost, messages: options.messages ?? core_1.MESSAGES[locale] });
33
63
  const widgetLabels = widgets_1.WIDGET_LABELS[locale];
34
- const dir = options.dir ?? (locale === 'ar' ? 'rtl' : undefined);
64
+ const dir = options.dir ?? (tag && (0, core_1.isRightToLeft)(tag) ? 'rtl' : undefined);
35
65
  const prefix = `fd${++mounts}`;
36
66
  const updaters = [];
37
67
  const cleanups = [];
38
- const uid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
68
+ const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
69
+ const uid = ownUid;
39
70
  const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
40
71
  (0, widgets_1.installStyles)(doc);
41
72
  function el(tag, attrs = {}, ...children) {
@@ -46,217 +77,434 @@ function mountViewer(host, options) {
46
77
  node.append(...children);
47
78
  return node;
48
79
  }
49
- /** Words with the icon a page names before them, when there is such an icon. */
80
+ /**
81
+ * Words with the icon a page names before them, when there is such an icon.
82
+ * The words sit in a <bdi>: left as written on a page of the other direction,
83
+ * they still run their own way, the icon still before them.
84
+ */
50
85
  function withIcon(name, text) {
51
86
  const icon = (0, widgets_1.drawIcon)(doc, name, options.icons);
52
- return icon ? [icon, text] : [text];
87
+ const words = el('bdi', {}, text);
88
+ return icon ? [icon, words] : [words];
53
89
  }
54
- const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: options.locale, 'data-max-width': page.maxWidth });
90
+ const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: tag, 'data-max-width': page.maxWidth });
91
+ (0, look_1.applyLook)(root, page.look);
55
92
  const confirm = options.confirm ?? dialogConfirm;
93
+ const say = (0, say_1.sayer)(root, el, labels.dismiss);
94
+ /** The tabs a step can show, by id: each shows its tab, false when it is hidden. */
95
+ const tabs = new Map();
96
+ /**
97
+ * A press runs its steps one run at a time: the button busy meanwhile, and
98
+ * not pressed again. A check or a save that stops it takes the focus to the
99
+ * first problem, and says which fields, as Save does.
100
+ */
101
+ async function press(button, run) {
102
+ if (button.hasAttribute('aria-busy'))
103
+ return;
104
+ button.setAttribute('aria-busy', 'true');
105
+ button.setAttribute('aria-disabled', 'true');
106
+ try {
107
+ const { reason } = await run();
108
+ if (reason === 'check' || reason === 'save') {
109
+ if (!form.getState().saveProblem)
110
+ announce(checkText(form.getState()));
111
+ focusFirstProblem();
112
+ }
113
+ }
114
+ finally {
115
+ button.removeAttribute('aria-busy');
116
+ button.removeAttribute('aria-disabled');
117
+ }
118
+ }
56
119
  // ---- the parts -------------------------------------------------------
57
- function fieldItem(node) {
58
- const def = page.fields[node.field];
59
- const id = uid(node.id);
60
- const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-type': def.type });
61
- if (node.colspan)
62
- wrapper.style.setProperty('--fd-span', String(node.colspan));
63
- const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, node.label ?? def.label);
64
- // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
65
- const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
66
- if (mark) {
67
- mark.classList.add('fd-valid-mark');
68
- label.append(mark);
69
- }
70
- const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
71
- if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
72
- // `for` stays: a custom field that puts the id on its own input is
73
- // labelled natively. Only a wrapper with a role (a radio group, say) may
74
- // carry a name; ARIA forbids naming a plain div or span.
75
- if (widget.element.getAttribute('role'))
76
- widget.element.setAttribute('aria-labelledby', label.id);
77
- label.addEventListener('click', () => widget.focus());
78
- }
79
- const helpText = node.help ?? def.help;
80
- const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
81
- // Not an alert of its own: a refused save is announced once, naming every field to look at.
82
- const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
83
- // A warning from the data source's onchange, beside the field whose change brought it.
84
- const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
85
- wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
86
- if (widget.destroy)
87
- cleanups.push(() => widget.destroy?.());
88
- updaters.push((state) => {
89
- const shown = form.node(node.id);
90
- wrapper.hidden = shown.invisible;
91
- wrapper.classList.toggle('fd-required', shown.required);
92
- const message = state.errors[node.field];
93
- error.hidden = !message;
94
- error.textContent = message ?? '';
95
- const warned = state.warning && state.warningField === node.field ? state.warning : '';
96
- warning.hidden = !warned;
97
- warning.textContent = warned;
120
+ /** Folded sections, and how to open each: a problem inside one has to be seen. */
121
+ const folds = new Map();
122
+ /** Every part drawn again from the state of the page shown. */
123
+ const renderNow = () => render(form.getState());
124
+ /** Set once the viewer is on the page; set again when it is taken away. */
125
+ let mounted = false;
126
+ let gone = false;
127
+ /** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
128
+ function spans(element, colspan) {
129
+ if (colspan)
130
+ element.style.setProperty('--fd-span', String(colspan));
131
+ }
132
+ /** Where the parts of a page, a tab or a step sit: one column, the page's labels. */
133
+ const top = (onPage) => ({ columns: 1, onPage, labels: page.look?.labels });
134
+ /**
135
+ * The parts of one page, drawn with its own form: the page shown, or a saved
136
+ * form placed in it. Inside, `form` and `page` are the scope's.
137
+ */
138
+ function drawer(scope) {
139
+ const { form, page } = scope;
140
+ const uid = (id) => ownUid(scope.prefix + id);
141
+ /** A part's updater, handed the state of the part's own form. */
142
+ const watch = (update) => updaters.push(scope.root ? update : () => update(form.getState()));
143
+ /** A field with its label, help and messages. `labels` is where labels sit where it is put; the title's own fields take none. */
144
+ function fieldItem(node, labels) {
145
+ const def = page.fields[node.field];
146
+ const id = uid(node.id);
147
+ const labelsAt = (0, place_1.labelPlace)(node, def.type, labels);
148
+ const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-path': scope.path + node.field, 'data-type': def.type, 'data-labels': labelsAt });
149
+ if (node.colspan)
150
+ wrapper.style.setProperty('--fd-span', String(node.colspan));
151
+ const labelText = node.label ?? def.label;
152
+ const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
153
+ // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
154
+ const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
98
155
  if (mark) {
99
- const value = state.values[node.field];
100
- const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
101
- const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
102
- wrapper.classList.toggle('fd-valid', valid);
103
- mark.toggleAttribute('hidden', !valid);
156
+ mark.classList.add('fd-valid-mark');
157
+ label.append(mark);
158
+ }
159
+ const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
160
+ // 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.
161
+ if (labelsAt === 'hidden' && !node.placeholder) {
162
+ const box = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
163
+ box?.setAttribute('placeholder', labelText);
164
+ }
165
+ if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
166
+ // `for` stays: a custom field that puts the id on its own input is
167
+ // labelled natively. Only a wrapper with a role (a radio group, say) may
168
+ // carry a name; ARIA forbids naming a plain div or span.
169
+ // A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
170
+ const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
171
+ named?.setAttribute('aria-labelledby', label.id);
172
+ label.addEventListener('click', () => widget.focus());
104
173
  }
105
- widget.update({
106
- value: state.values[node.field],
107
- values: state.values,
108
- readonly: shown.readonly || locked,
109
- required: shown.required,
110
- invalid: !!message,
111
- describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
174
+ const helpText = node.help ?? def.help;
175
+ const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
176
+ // Not an alert of its own: a refused save is announced once, naming every field to look at.
177
+ const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
178
+ // A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
179
+ const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
180
+ wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
181
+ if (widget.destroy)
182
+ cleanups.push(() => widget.destroy?.());
183
+ // An answer rule's warning waits until the person leaves the field: no advice
184
+ // mid-word. One already shown stays while they put it right, and goes once they have.
185
+ let typing = false;
186
+ let advice = '';
187
+ wrapper.addEventListener('focusin', () => (typing = true));
188
+ wrapper.addEventListener('focusout', (event) => {
189
+ if (wrapper.contains(event.relatedTarget))
190
+ return;
191
+ typing = false;
192
+ // Not at once: focus also leaves when a redraw removes the focused part
193
+ // (a deleted line's button), and that redraw must finish first.
194
+ queueMicrotask(() => update(form.getState()));
112
195
  });
113
- });
114
- return wrapper;
115
- }
116
- function hideWhen(element, id) {
117
- updaters.push(() => {
118
- element.hidden = form.node(id).invisible;
119
- });
120
- }
121
- function buttonItem(node) {
122
- const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
123
- button.addEventListener('click', async () => {
124
- if (node.confirm && !(await confirm(node.confirm)))
125
- return;
126
- await form.runAction(node.id);
127
- });
128
- hideWhen(button, node.id);
129
- return button;
130
- }
131
- function textItem(node) {
132
- const style = node.style ?? 'paragraph';
133
- const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
134
- hideWhen(element, node.id);
135
- return element;
136
- }
137
- function slotItem(node) {
138
- const element = el('div', { class: 'fd-slot', 'data-slot': node.name, 'data-node': node.id });
139
- const cleanup = options.slots?.[node.name]?.(element, { form, name: node.name });
140
- if (cleanup)
141
- cleanups.push(cleanup);
142
- hideWhen(element, node.id);
143
- return element;
144
- }
145
- function grid(children, columns = 1) {
146
- const box = el('div', { class: 'fd-grid' });
147
- box.style.setProperty('--fd-columns', String((0, core_1.wideColumns)(columns)));
148
- // Counts given for the narrower widths replace the skin's own stacking there.
149
- if (typeof columns === 'object') {
150
- for (const width of ['medium', 'narrow']) {
151
- const count = columns[width];
152
- if (count === undefined)
153
- continue;
154
- box.setAttribute(`data-columns-${width}`, String(count));
155
- box.style.setProperty(`--fd-columns-${width}`, String(count));
196
+ const update = (state) => {
197
+ const shown = form.node(node.id);
198
+ (0, dom_1.setHidden)(wrapper, shown.invisible);
199
+ wrapper.classList.toggle('fd-required', shown.required);
200
+ const message = state.errors[node.field];
201
+ (0, dom_1.setHidden)(error, !message);
202
+ (0, dom_1.setText)(error, message ?? '');
203
+ const now = state.warnings[node.field] ?? '';
204
+ if (!typing || !now)
205
+ advice = now;
206
+ // An error shown says enough: the rule's warning gives way to it.
207
+ const warned = [message ? '' : advice, state.warning && state.warningField === node.field ? state.warning : ''].filter(Boolean).join(' ');
208
+ (0, dom_1.setHidden)(warning, !warned);
209
+ (0, dom_1.setText)(warning, warned);
210
+ if (mark) {
211
+ const value = state.values[node.field];
212
+ const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
213
+ const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
214
+ wrapper.classList.toggle('fd-valid', valid);
215
+ mark.toggleAttribute('hidden', !valid);
216
+ }
217
+ widget.update({
218
+ value: state.values[node.field],
219
+ values: state.values,
220
+ readonly: shown.readonly || locked || scope.locked(),
221
+ required: shown.required,
222
+ invalid: !!message,
223
+ describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
224
+ });
225
+ };
226
+ watch(update);
227
+ return wrapper;
228
+ }
229
+ function hideWhen(element, id) {
230
+ watch(() => (0, dom_1.setHidden)(element, form.node(id).invisible));
231
+ }
232
+ function buttonItem(node) {
233
+ const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
234
+ spans(button, node.colspan);
235
+ // Its confirmation, then its steps: the form asks, through the viewer.
236
+ button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
237
+ hideWhen(button, node.id);
238
+ return button;
239
+ }
240
+ function textItem(node) {
241
+ const style = node.style ?? 'paragraph';
242
+ const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
243
+ spans(element, node.colspan);
244
+ hideWhen(element, node.id);
245
+ return element;
246
+ }
247
+ function slotItem(node) {
248
+ const element = el('div', { class: 'fd-slot', 'data-slot': node.name, 'data-node': node.id });
249
+ const cleanup = options.slots?.[node.name]?.(element, { form, name: node.name });
250
+ if (cleanup)
251
+ cleanups.push(cleanup);
252
+ hideWhen(element, node.id);
253
+ return element;
254
+ }
255
+ /**
256
+ * A grid of parts. With `columns` null, it has none of its own: it lays its
257
+ * parts on the columns of the grid round it (an arrangement on its tracks).
258
+ */
259
+ function grid(children, columns, place) {
260
+ const box = el('div', { class: 'fd-grid' });
261
+ if (columns !== null) {
262
+ box.style.setProperty('--fd-columns', String((0, core_1.wideColumns)(columns)));
263
+ // Counts given for the narrower widths replace the skin's own stacking there.
264
+ if (typeof columns === 'object') {
265
+ for (const width of ['medium', 'narrow']) {
266
+ const count = columns[width];
267
+ if (count === undefined)
268
+ continue;
269
+ box.setAttribute(`data-columns-${width}`, String(count));
270
+ box.style.setProperty(`--fd-columns-${width}`, String(count));
271
+ }
272
+ }
156
273
  }
274
+ box.append(...children.map((child) => item(child, place)));
275
+ return box;
157
276
  }
158
- box.append(...children.map(item));
159
- return box;
160
- }
161
- /** Folded sections, and how to open each: a problem inside one has to be seen. */
162
- const folds = new Map();
163
- function sectionItem(node) {
164
- const section = el('fieldset', { class: 'fd-section', 'data-node': node.id });
165
- const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
166
- const content = grid(node.children, node.columns);
167
- if (node.title && node.collapsible) {
168
- content.id = uid(`${node.id}-content`);
169
- 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));
170
- let open = node.collapsed !== true;
171
- const show = (next) => {
172
- open = next;
173
- toggle.setAttribute('aria-expanded', String(open));
174
- content.hidden = !open;
175
- if (description)
176
- description.hidden = !open;
177
- section.classList.toggle('fd-section-folded', !open);
277
+ function sectionItem(node, place) {
278
+ const plan = (0, place_1.planSection)(node, place);
279
+ // An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
280
+ const section = el(plan.arrangement ? 'div' : 'fieldset', {
281
+ class: 'fd-section',
282
+ 'data-node': node.id,
283
+ 'data-style': plan.style,
284
+ 'data-place': plan.at,
285
+ 'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
286
+ });
287
+ spans(section, node.colspan);
288
+ if (node.labelWidth)
289
+ section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
290
+ const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
291
+ const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
292
+ if (node.title && node.collapsible) {
293
+ content.id = uid(`${node.id}-content`);
294
+ 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));
295
+ let open = node.collapsed !== true;
296
+ const show = (next) => {
297
+ open = next;
298
+ toggle.setAttribute('aria-expanded', String(open));
299
+ content.hidden = !open;
300
+ if (description)
301
+ description.hidden = !open;
302
+ section.classList.toggle('fd-section-folded', !open);
303
+ };
304
+ toggle.addEventListener('click', () => show(!open));
305
+ folds.set(section, () => show(true));
306
+ show(open);
307
+ section.append(el('legend', { class: 'fd-section-title' }, toggle));
308
+ }
309
+ else if (node.title) {
310
+ section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
311
+ }
312
+ if (description)
313
+ section.append(description);
314
+ section.append(content);
315
+ hideWhen(section, node.id);
316
+ return section;
317
+ }
318
+ function tabsItem(node, place) {
319
+ const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
320
+ spans(box, node.colspan);
321
+ const list = el('div', { class: 'fd-tablist', role: 'tablist' });
322
+ // The tab someone picked. Until they pick, the first visible tab is open —
323
+ // a tab hidden while the record loads must not leave its neighbour open.
324
+ let picked = null;
325
+ let active = node.children[0].id;
326
+ /** A tab shown, by a person or a step: the page's steps for it run. */
327
+ const show = (tab) => {
328
+ picked = active = tab;
329
+ renderNow();
330
+ form.shown(tab);
178
331
  };
179
- toggle.addEventListener('click', () => show(!open));
180
- folds.set(section, () => show(true));
181
- show(open);
182
- section.append(el('legend', { class: 'fd-section-title' }, toggle));
183
- }
184
- else if (node.title) {
185
- section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
186
- }
187
- if (description)
188
- section.append(description);
189
- section.append(content);
190
- hideWhen(section, node.id);
191
- return section;
192
- }
193
- function tabsItem(node) {
194
- const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
195
- const list = el('div', { class: 'fd-tablist', role: 'tablist' });
196
- // The tab someone picked. Until they pick, the first visible tab is open —
197
- // a tab hidden while the record loads must not leave its neighbour open.
198
- let picked = null;
199
- let active = node.children[0].id;
200
- const parts = node.children.map((tab) => {
201
- const tabId = uid(`${tab.id}-tab`);
202
- const panelId = uid(`${tab.id}-panel`);
203
- 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));
204
- const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children));
205
- button.addEventListener('click', () => {
206
- picked = active = tab.id;
207
- render(form.getState());
332
+ const parts = node.children.map((tab) => {
333
+ const tabId = uid(`${tab.id}-tab`);
334
+ const panelId = uid(`${tab.id}-panel`);
335
+ 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));
336
+ // A tab's parts sit where the tabs do: on the page, or in the box round them.
337
+ 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 }));
338
+ button.addEventListener('click', () => show(tab.id));
339
+ if (!tabs.has(tab.id))
340
+ tabs.set(tab.id, () => !form.node(tab.id).invisible && (show(tab.id), true));
341
+ list.append(button);
342
+ return { tab, button, panel };
208
343
  });
209
- list.append(button);
210
- return { tab, button, panel };
211
- });
212
- list.addEventListener('keydown', (event) => {
213
- const shown = parts.filter((p) => !p.button.hidden);
214
- const at = shown.findIndex((p) => p.tab.id === active);
215
- const move = { ArrowRight: 1, ArrowLeft: -1, Home: -at, End: shown.length - 1 - at }[event.key];
216
- if (move === undefined || at === -1)
217
- return;
218
- event.preventDefault();
219
- const rtl = root.getAttribute('dir') === 'rtl' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft');
220
- const next = shown[(at + (rtl ? -move : move) + shown.length) % shown.length];
221
- picked = active = next.tab.id;
222
- render(form.getState());
223
- next.button.focus();
224
- });
225
- box.append(list, ...parts.map((p) => p.panel));
226
- updaters.push(() => {
227
- box.hidden = form.node(node.id).invisible;
228
- const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
229
- const keep = picked !== null && shown.some((p) => p.tab.id === picked);
230
- if (keep)
231
- active = picked;
232
- else if (shown.length)
233
- active = shown[0].tab.id;
234
- for (const p of parts) {
235
- const selected = p.tab.id === active;
236
- p.button.hidden = !shown.includes(p);
237
- p.button.setAttribute('aria-selected', String(selected));
238
- p.button.tabIndex = selected ? 0 : -1;
239
- p.panel.hidden = !selected;
344
+ list.addEventListener('keydown', (event) => {
345
+ const shown = parts.filter((p) => !p.button.hidden);
346
+ const at = shown.findIndex((p) => p.tab.id === active);
347
+ const move = { ArrowRight: 1, ArrowLeft: -1, Home: -at, End: shown.length - 1 - at }[event.key];
348
+ if (move === undefined || at === -1)
349
+ return;
350
+ event.preventDefault();
351
+ const rtl = root.getAttribute('dir') === 'rtl' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft');
352
+ const next = shown[(at + (rtl ? -move : move) + shown.length) % shown.length];
353
+ show(next.tab.id);
354
+ next.button.focus();
355
+ });
356
+ box.append(list, ...parts.map((p) => p.panel));
357
+ watch(() => {
358
+ box.hidden = form.node(node.id).invisible;
359
+ const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
360
+ const keep = picked !== null && shown.some((p) => p.tab.id === picked);
361
+ if (keep)
362
+ active = picked;
363
+ else if (shown.length)
364
+ active = shown[0].tab.id;
365
+ for (const p of parts) {
366
+ const selected = p.tab.id === active;
367
+ p.button.hidden = !shown.includes(p);
368
+ p.button.setAttribute('aria-selected', String(selected));
369
+ p.button.tabIndex = selected ? 0 : -1;
370
+ p.panel.hidden = !selected;
371
+ }
372
+ });
373
+ return box;
374
+ }
375
+ function item(node, place) {
376
+ switch (node.type) {
377
+ case 'field':
378
+ return fieldItem(node, place.labels);
379
+ case 'button':
380
+ return buttonItem(node);
381
+ case 'text':
382
+ return textItem(node);
383
+ case 'slot':
384
+ return slotItem(node);
385
+ case 'section':
386
+ return sectionItem(node, place);
387
+ case 'tabs':
388
+ return tabsItem(node, place);
389
+ case 'divider':
390
+ case 'spacer':
391
+ case 'image':
392
+ return blockItem(node);
393
+ case 'form':
394
+ return formItem(node, place);
240
395
  }
241
- });
242
- return box;
243
- }
244
- function item(node) {
245
- switch (node.type) {
246
- case 'field':
247
- return fieldItem(node);
248
- case 'button':
249
- return buttonItem(node);
250
- case 'text':
251
- return textItem(node);
252
- case 'slot':
253
- return slotItem(node);
254
- case 'section':
255
- return sectionItem(node);
256
- case 'tabs':
257
- return tabsItem(node);
258
396
  }
397
+ /** A line across the row, empty room, or a picture: shown and hidden by its rule like any part. */
398
+ function blockItem(node) {
399
+ const element = node.type === 'divider' ? el('hr', { class: 'fd-divider', 'data-node': node.id })
400
+ : node.type === 'spacer' ? el('div', { class: 'fd-block fd-spacer', 'aria-hidden': 'true', 'data-node': node.id })
401
+ : figure(node);
402
+ if (node.type !== 'divider' && node.colspan)
403
+ element.style.setProperty('--fd-span', String(node.colspan));
404
+ hideWhen(element, node.id);
405
+ return element;
406
+ }
407
+ /** 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. */
408
+ function figure(node) {
409
+ const image = el('img', { class: 'fd-image', src: node.src, alt: node.alt, loading: 'lazy' });
410
+ const href = node.href && /^(https?:\/\/|mailto:)/i.test(node.href) ? node.href : undefined;
411
+ 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)] : []));
412
+ const width = typeof node.width === 'number' ? `${node.width}px` : node.width && { small: '160px', medium: '320px', large: '480px', full: '100%' }[node.width];
413
+ if (width)
414
+ element.style.setProperty('--fd-image-width', width);
415
+ return element;
416
+ }
417
+ /**
418
+ * A saved form placed here, drawn from the page the app gives for its id
419
+ * with a form of its own inside this one. A quiet placeholder while the
420
+ * page comes; words in its place when it cannot be found, would hold
421
+ * itself, or cannot sit in a form.
422
+ */
423
+ function formItem(node, place) {
424
+ const box = el('fieldset', {
425
+ class: 'fd-section fd-form-part',
426
+ 'data-node': node.id,
427
+ 'data-style': 'card',
428
+ 'data-on-page': place.onPage ? '' : undefined,
429
+ 'aria-busy': 'true',
430
+ });
431
+ spans(box, node.colspan);
432
+ const note = el('p', { class: 'fd-form-part-note' }, labels.loading);
433
+ box.append(note);
434
+ hideWhen(box, node.id);
435
+ const refuse = (words) => {
436
+ box.removeAttribute('aria-busy');
437
+ note.textContent = words;
438
+ note.classList.add('fd-form-part-problem');
439
+ };
440
+ const draw = (found) => {
441
+ if (gone)
442
+ return;
443
+ if (!found)
444
+ return refuse(fill(labels.formMissing, { page: node.page }));
445
+ const saved = shownAs(found);
446
+ const name = saved.title || found.id;
447
+ const at = scope.chain.findIndex((p) => p.id === found.id);
448
+ if (at !== -1)
449
+ return refuse(fill(labels.formInItself, { page: name, chain: [...scope.chain.slice(at).map((p) => p.title), name].join(' → ') }));
450
+ if (found.layout.type !== 'sections' && found.layout.type !== 'tabs')
451
+ return refuse(fill(labels.formNotPlaceable, { page: name }));
452
+ const checked = (0, core_1.checkPage)(saved);
453
+ let inner;
454
+ try {
455
+ if (!checked.ok)
456
+ throw new Error(checked.issues.map((i) => `${i.path}: ${i.message}`).join('; '));
457
+ inner = form.embed(node.id, saved);
458
+ }
459
+ catch (error) {
460
+ console.warn(`Fieldia: the saved form "${found.id}" cannot be shown: ${error.message}`);
461
+ return refuse(fill(labels.formBroken, { page: name }));
462
+ }
463
+ const title = node.title ?? saved.title ?? '';
464
+ box.setAttribute('data-title', title);
465
+ if (title)
466
+ box.prepend(el('legend', { class: 'fd-section-title' }, ...withIcon(undefined, title)));
467
+ const own = drawer({
468
+ root: false,
469
+ form: inner,
470
+ page: saved,
471
+ prefix: `${scope.prefix}${node.id}-`,
472
+ chain: [...scope.chain, { id: found.id, title: name }],
473
+ locked: () => scope.locked() || form.node(node.id).readonly,
474
+ path: `${scope.path}${node.name}.`,
475
+ });
476
+ const layout = saved.layout;
477
+ // Its parts sit in the part's box: a card there draws none of its own.
478
+ note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels }));
479
+ box.removeAttribute('aria-busy');
480
+ cleanups.push(inner.subscribe(renderNow));
481
+ if (mounted)
482
+ renderNow();
483
+ };
484
+ let asked;
485
+ try {
486
+ asked = (0, related_1.findPage)(options, node.version === undefined ? { id: node.page } : { id: node.page, version: node.version });
487
+ }
488
+ catch {
489
+ asked = null;
490
+ }
491
+ if (asked instanceof Promise)
492
+ asked.then(draw, () => draw(null));
493
+ else
494
+ draw(asked);
495
+ return box;
496
+ }
497
+ return { item, grid, fieldItem, buttonItem, hideWhen, slotItem };
259
498
  }
499
+ const { item, grid, fieldItem, buttonItem, hideWhen, slotItem } = drawer({
500
+ root: true,
501
+ form,
502
+ page,
503
+ prefix: '',
504
+ chain: [{ id: page.id, title: page.title || page.id }],
505
+ locked: () => false,
506
+ path: '',
507
+ });
260
508
  // ---- shared chrome ----------------------------------------------------------
261
509
  /** The whole form locked: every field read-only, no Save; buttons still run. */
262
510
  let locked = options.readonly === true;
@@ -293,18 +541,27 @@ function mountViewer(host, options) {
293
541
  retry.addEventListener('click', () => void submitOrSave());
294
542
  const status = el('div', { class: 'fd-status-box' }, statusText, retry);
295
543
  const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
296
- /** "Not saved. Check: Email, Phone": the fields with a problem, in the order they show. */
544
+ /**
545
+ * "Not saved. Check: Email, Phone": the fields with a problem, in the order
546
+ * they show; one inside a saved form with that form's title, "Street (Home address)".
547
+ */
297
548
  function checkText(state) {
298
- const names = [];
549
+ const fieldsShown = [...root.querySelectorAll('.fd-field[data-path]')];
550
+ const found = [];
299
551
  for (const key of Object.keys(state.errors)) {
300
- const field = key.split('.')[0];
301
- const shown = root.querySelector(`.fd-field[data-field="${field}"]:not([hidden]) > .fd-label`);
302
- const name = shown?.firstChild?.textContent ?? page.fields[field]?.label ?? field;
303
- if (!names.includes(name))
304
- names.push(name);
305
- }
306
- const inOrder = [...root.querySelectorAll('.fd-field[data-field] > .fd-label')].map((label) => label.firstChild?.textContent ?? '');
307
- names.sort((a, b) => (inOrder.indexOf(a) + 1 || Infinity) - (inOrder.indexOf(b) + 1 || Infinity));
552
+ // The field itself, or — for a line's — the table it is in.
553
+ let shown;
554
+ for (let path = key; path && !shown; path = path.slice(0, Math.max(0, path.lastIndexOf('.')))) {
555
+ shown = fieldsShown.find((field) => field.getAttribute('data-path') === path && !field.hidden);
556
+ }
557
+ const first = key.split('.')[0];
558
+ const label = shown?.querySelector(':scope > .fd-label')?.firstChild?.textContent ?? page.fields[first]?.label ?? first;
559
+ const within = shown?.closest('.fd-form-part')?.getAttribute('data-title');
560
+ const name = within ? `${label} (${within})` : label;
561
+ if (!found.some((f) => f.name === name))
562
+ found.push({ name, at: shown ? fieldsShown.indexOf(shown) : Infinity });
563
+ }
564
+ const names = found.sort((a, b) => a.at - b.at).map((f) => f.name);
308
565
  return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
309
566
  }
310
567
  let quietTimer;
@@ -316,8 +573,10 @@ function mountViewer(host, options) {
316
573
  : state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
317
574
  : state.status === 'error' ? state.error ?? ''
318
575
  : '';
319
- statusText.textContent = text;
320
- retry.hidden = !(state.status === 'error' && problem?.kind === 'other');
576
+ // A problem is said once, at once, by the announcer (or the banner, or a dialog): the status shows it and keeps quiet.
577
+ (0, dom_1.setAttr)(statusText, 'aria-live', state.status === 'error' && problem ? 'off' : 'polite');
578
+ (0, dom_1.setText)(statusText, text);
579
+ (0, dom_1.setHidden)(retry, !(state.status === 'error' && problem?.kind === 'other'));
321
580
  statusText.classList.toggle('fd-status-error', state.status === 'error');
322
581
  statusText.classList.toggle('fd-status-saved', state.status === 'saved');
323
582
  // A toast shows while there is something to say, and lets "Saved" go after a moment.
@@ -362,7 +621,7 @@ function mountViewer(host, options) {
362
621
  discardDraft.addEventListener('click', () => form.discardDraft());
363
622
  draft.append(draftText, restore, discardDraft);
364
623
  updaters.push((state) => {
365
- draft.hidden = !state.draft;
624
+ (0, dom_1.setHidden)(draft, !state.draft);
366
625
  if (state.draft)
367
626
  draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
368
627
  });
@@ -414,7 +673,7 @@ function mountViewer(host, options) {
414
673
  }
415
674
  // ---- the four page layouts ---------------------------------------------------
416
675
  function sectionsLayout(node) {
417
- const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map(item));
676
+ const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
418
677
  const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
419
678
  const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
420
679
  if (page.data.kind === 'record') {
@@ -424,12 +683,12 @@ function mountViewer(host, options) {
424
683
  actions.insertBefore(discard, action);
425
684
  updaters.push((state) => {
426
685
  const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
427
- action.hidden = !dirty;
428
- discard.hidden = !dirty;
686
+ (0, dom_1.setHidden)(action, !dirty);
687
+ (0, dom_1.setHidden)(discard, !dirty);
429
688
  });
430
689
  }
431
690
  else
432
- updaters.push(() => (action.hidden = locked));
691
+ updaters.push(() => (0, dom_1.setHidden)(action, locked));
433
692
  if (editSwitch)
434
693
  actions.prepend(editSwitch);
435
694
  if (options.showActions === false)
@@ -467,7 +726,8 @@ function mountViewer(host, options) {
467
726
  panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, ...withIcon(step.icon, step.label)));
468
727
  if (step.description)
469
728
  panel.append(el('p', { class: 'fd-section-description' }, step.description));
470
- panel.append(grid(step.children));
729
+ // The step is the box: a card in it draws none of its own.
730
+ panel.append(grid(step.children, 1, top(false)));
471
731
  box.append(panel);
472
732
  return { step, panel };
473
733
  });
@@ -566,7 +826,7 @@ function mountViewer(host, options) {
566
826
  const icon = (0, widgets_1.drawIcon)(doc, stat.icon, options.icons);
567
827
  const words = el('span', { class: 'fd-stat-words' }, value, el('span', { class: 'fd-stat-label' }, stat.label));
568
828
  const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, ...(icon ? [icon, words] : [words]));
569
- button.addEventListener('click', () => void form.runAction(stat.id));
829
+ button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
570
830
  updaters.push((state) => {
571
831
  button.hidden = form.node(stat.id).invisible;
572
832
  const count = stat.field ? state.values[stat.field] : null;
@@ -610,12 +870,12 @@ function mountViewer(host, options) {
610
870
  if (node.title) {
611
871
  const title = el('div', { class: 'fd-title' });
612
872
  if (node.title.above?.length)
613
- title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map(fieldItem)));
873
+ title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
614
874
  title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
615
875
  if (node.title.subtitleField)
616
876
  title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
617
877
  if (node.title.below?.length)
618
- title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map(fieldItem)));
878
+ title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map((part) => fieldItem(part))));
619
879
  const row = el('div', { class: 'fd-title-row' }, title);
620
880
  if (node.title.avatarField) {
621
881
  const avatar = fieldItem({ type: 'field', id: '#avatar', field: node.title.avatarField });
@@ -626,7 +886,7 @@ function mountViewer(host, options) {
626
886
  }
627
887
  if (node.statusbar && underTitle)
628
888
  card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
629
- card.append(grid(node.children));
889
+ card.append(grid(node.children, 1, top(false)));
630
890
  if (atFoot && options.showActions !== false) {
631
891
  card.append(foot);
632
892
  updaters.push((state) => {
@@ -672,7 +932,7 @@ function mountViewer(host, options) {
672
932
  labels,
673
933
  locale,
674
934
  fill,
675
- confirm,
935
+ press,
676
936
  withIcon,
677
937
  uid,
678
938
  icons: options.icons,
@@ -769,15 +1029,27 @@ function mountViewer(host, options) {
769
1029
  const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
770
1030
  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])));
771
1031
  const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
1032
+ const doc = root.ownerDocument;
1033
+ const opener = doc.activeElement;
772
1034
  const close = (answer) => {
773
1035
  backdrop.remove();
1036
+ // Back to what had focus when it asked; or, when that let go of it — Save hides while it saves — to Save, shown again.
1037
+ const shown = (e) => !!e && e !== doc.body && e.isConnected && !e.closest('[hidden]');
1038
+ const back = shown(opener) ? opener : root.querySelector('button[type="submit"]:not([hidden])');
1039
+ back?.focus();
774
1040
  resolve(answer);
775
1041
  };
776
1042
  cancel.addEventListener('click', () => close(false));
777
1043
  ok.addEventListener('click', () => close(true));
1044
+ // Its keys are its own: Escape answers No here, and closes no dialog or panel under it.
778
1045
  backdrop.addEventListener('keydown', (event) => {
779
- if (event.key === 'Escape')
1046
+ event.stopPropagation();
1047
+ if (event.key === 'Escape') {
1048
+ event.preventDefault();
780
1049
  close(false);
1050
+ }
1051
+ else
1052
+ (0, focus_trap_1.keepTabIn)(dialog, event);
781
1053
  });
782
1054
  root.append(backdrop);
783
1055
  ok.focus();
@@ -790,7 +1062,13 @@ function mountViewer(host, options) {
790
1062
  }
791
1063
  host.append(root);
792
1064
  const unsubscribe = form.subscribe(render);
1065
+ // A run that could not go on says why: a step that cannot run here, or what the app threw. A stop with words the form says itself.
1066
+ cleanups.push(form.on('run', ({ result }) => {
1067
+ if (result.reason === 'cannot' || (result.reason === 'app' && result.error !== undefined))
1068
+ actionHost.say(result.message ?? '', result.reason === 'app' ? 'danger' : 'warning');
1069
+ }));
793
1070
  render(form.getState());
1071
+ mounted = true;
794
1072
  if (form.getState().status === 'idle')
795
1073
  void form.load();
796
1074
  return {
@@ -817,7 +1095,15 @@ function mountViewer(host, options) {
817
1095
  render(form.getState());
818
1096
  },
819
1097
  isReadonly: () => locked,
1098
+ on: (event, listener) => {
1099
+ const off = form.on(event, listener);
1100
+ cleanups.push(off);
1101
+ return off;
1102
+ },
1103
+ setValues: (values) => form.setValues(values),
1104
+ run: (steps, context) => form.run(steps, context),
820
1105
  destroy() {
1106
+ gone = true;
821
1107
  unsubscribe();
822
1108
  for (const cleanup of cleanups)
823
1109
  cleanup();
@@ -827,6 +1113,8 @@ function mountViewer(host, options) {
827
1113
  },
828
1114
  };
829
1115
  }
1116
+ /** The boxes a label can be shown in, as the words of an empty box. */
1117
+ const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
830
1118
  /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
831
1119
  const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
832
1120
  //# sourceMappingURL=viewer.js.map