@fieldia/viewer 0.10.0 → 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 (70) hide show
  1. package/README.md +126 -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 +4 -4
  9. package/bundle/fieldia.js.map +4 -4
  10. package/package.json +3 -3
  11. package/src/lib/dialog.d.mts +29 -2
  12. package/src/lib/dialog.d.ts +29 -2
  13. package/src/lib/dialog.js +94 -27
  14. package/src/lib/dialog.js.map +1 -1
  15. package/src/lib/dialog.mjs +92 -27
  16. package/src/lib/dialog.mjs.map +1 -1
  17. package/src/lib/labels.d.mts +14 -0
  18. package/src/lib/labels.d.ts +14 -0
  19. package/src/lib/labels.js +6 -0
  20. package/src/lib/labels.js.map +1 -1
  21. package/src/lib/labels.mjs +6 -0
  22. package/src/lib/labels.mjs.map +1 -1
  23. package/src/lib/list.d.mts +3 -2
  24. package/src/lib/list.d.ts +3 -2
  25. package/src/lib/list.js +5 -5
  26. package/src/lib/list.js.map +1 -1
  27. package/src/lib/list.mjs +5 -5
  28. package/src/lib/list.mjs.map +1 -1
  29. package/src/lib/locales/ar.js +6 -0
  30. package/src/lib/locales/ar.js.map +1 -1
  31. package/src/lib/locales/ar.mjs +6 -0
  32. package/src/lib/locales/ar.mjs.map +1 -1
  33. package/src/lib/locales/de.js +6 -0
  34. package/src/lib/locales/de.js.map +1 -1
  35. package/src/lib/locales/de.mjs +6 -0
  36. package/src/lib/locales/de.mjs.map +1 -1
  37. package/src/lib/locales/fr.js +6 -0
  38. package/src/lib/locales/fr.js.map +1 -1
  39. package/src/lib/locales/fr.mjs +6 -0
  40. package/src/lib/locales/fr.mjs.map +1 -1
  41. package/src/lib/look.d.mts +7 -3
  42. package/src/lib/look.d.ts +7 -3
  43. package/src/lib/look.js +72 -15
  44. package/src/lib/look.js.map +1 -1
  45. package/src/lib/look.mjs +72 -15
  46. package/src/lib/look.mjs.map +1 -1
  47. package/src/lib/open.d.mts +24 -0
  48. package/src/lib/open.d.ts +24 -0
  49. package/src/lib/open.js +81 -0
  50. package/src/lib/open.js.map +1 -0
  51. package/src/lib/open.mjs +78 -0
  52. package/src/lib/open.mjs.map +1 -0
  53. package/src/lib/related.d.mts +19 -0
  54. package/src/lib/related.d.ts +19 -0
  55. package/src/lib/related.js +15 -2
  56. package/src/lib/related.js.map +1 -1
  57. package/src/lib/related.mjs +14 -2
  58. package/src/lib/related.mjs.map +1 -1
  59. package/src/lib/say.d.mts +9 -0
  60. package/src/lib/say.d.ts +9 -0
  61. package/src/lib/say.js +45 -0
  62. package/src/lib/say.js.map +1 -0
  63. package/src/lib/say.mjs +42 -0
  64. package/src/lib/say.mjs.map +1 -0
  65. package/src/lib/viewer.d.mts +33 -5
  66. package/src/lib/viewer.d.ts +33 -5
  67. package/src/lib/viewer.js +464 -279
  68. package/src/lib/viewer.js.map +1 -1
  69. package/src/lib/viewer.mjs +465 -280
  70. package/src/lib/viewer.mjs.map +1 -1
@@ -1,10 +1,12 @@
1
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
- import { pageDialogs } from './related.mjs';
3
+ import { findPage, pageDialogs } from './related.mjs';
4
4
  import { listView } from './list.mjs';
5
5
  import { applyLook } from './look.mjs';
6
6
  import { labelPlace, planSection } from './place.mjs';
7
7
  import { setAttr, setHidden, setText } from './dom.mjs';
8
+ import { openPage } from './open.mjs';
9
+ import { sayer } from './say.mjs';
8
10
  import { ownLocale, VIEWER_LABELS } from './labels.mjs';
9
11
  import { keepTabIn } from './focus-trap.mjs';
10
12
  export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
@@ -25,18 +27,40 @@ export function mountViewer(host, options) {
25
27
  const tag = options.locale ?? checked.page.language;
26
28
  const localized = tag ? localizePage(checked.page, tag) : checked.page;
27
29
  const page = options.translate ? translatePage(localized, options.translate) : localized;
30
+ /** Another page as this one is shown: in its language, through the app's translator. */
31
+ const shownAs = (other) => {
32
+ const own = tag ? localizePage(other, tag) : other;
33
+ return options.translate ? translatePage(own, options.translate) : own;
34
+ };
28
35
  const locale = ownLocale(tag);
29
36
  const preferences = options.preferences ?? browserPreferences();
30
37
  const dialogs = pageDialogs(options);
31
38
  const ownsForm = !options.form;
32
- const form = options.form ?? createForm({ ...options, page, messages: options.messages ?? MESSAGES[locale] });
33
39
  const labels = { ...VIEWER_LABELS[locale], ...options.labels };
40
+ /**
41
+ * What the page's steps ask of the screen: a page opened, words said, a
42
+ * question asked, a tab shown — the viewer's own, under the app's. A form
43
+ * made elsewhere (`form`) keeps the host it was made with.
44
+ */
45
+ const actionHost = {
46
+ open: (request) => openPage({ options, place: host, root, title: (other) => shownAs(other).title || other.id, back: labels.back, missing: (id) => fill(labels.pageMissing, { page: id }) }, request),
47
+ say: (message, tone) => say(message, tone),
48
+ ask: (message) => confirm(message),
49
+ show: (target) => {
50
+ const show = tabs.get(target);
51
+ if (!show?.())
52
+ throw new Error(`No tab "${target}" can be shown here`);
53
+ },
54
+ ...options.host,
55
+ };
56
+ const form = options.form ?? createForm({ ...options, page, host: actionHost, messages: options.messages ?? MESSAGES[locale] });
34
57
  const widgetLabels = WIDGET_LABELS[locale];
35
58
  const dir = options.dir ?? (tag && isRightToLeft(tag) ? 'rtl' : undefined);
36
59
  const prefix = `fd${++mounts}`;
37
60
  const updaters = [];
38
61
  const cleanups = [];
39
- const uid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
62
+ const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
63
+ const uid = ownUid;
40
64
  const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
41
65
  installStyles(doc);
42
66
  function el(tag, attrs = {}, ...children) {
@@ -60,287 +84,421 @@ export function mountViewer(host, options) {
60
84
  const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: tag, 'data-max-width': page.maxWidth });
61
85
  applyLook(root, page.look);
62
86
  const confirm = options.confirm ?? dialogConfirm;
63
- // ---- the parts -------------------------------------------------------
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) {
66
- const def = page.fields[node.field];
67
- const id = uid(node.id);
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 });
70
- if (node.colspan)
71
- wrapper.style.setProperty('--fd-span', String(node.colspan));
72
- const labelText = node.label ?? def.label;
73
- const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
74
- // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
75
- const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? drawIcon(doc, 'check') : null;
76
- if (mark) {
77
- mark.classList.add('fd-valid-mark');
78
- label.append(mark);
79
- }
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
- }
86
- if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
87
- // `for` stays: a custom field that puts the id on its own input is
88
- // labelled natively. Only a wrapper with a role (a radio group, say) may
89
- // carry a name; ARIA forbids naming a plain div or span.
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);
93
- label.addEventListener('click', () => widget.focus());
94
- }
95
- const helpText = node.help ?? def.help;
96
- const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
97
- // Not an alert of its own: a refused save is announced once, naming every field to look at.
98
- const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
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);
102
- if (widget.destroy)
103
- cleanups.push(() => widget.destroy?.());
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) => {
118
- const shown = form.node(node.id);
119
- setHidden(wrapper, shown.invisible);
120
- wrapper.classList.toggle('fd-required', shown.required);
121
- const message = state.errors[node.field];
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);
131
- if (mark) {
132
- const value = state.values[node.field];
133
- const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
134
- const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
135
- wrapper.classList.toggle('fd-valid', valid);
136
- mark.toggleAttribute('hidden', !valid);
87
+ const say = sayer(root, el, labels.dismiss);
88
+ /** The tabs a step can show, by id: each shows its tab, false when it is hidden. */
89
+ const tabs = new Map();
90
+ /**
91
+ * A press runs its steps one run at a time: the button busy meanwhile, and
92
+ * not pressed again. A check or a save that stops it takes the focus to the
93
+ * first problem, and says which fields, as Save does.
94
+ */
95
+ async function press(button, run) {
96
+ if (button.hasAttribute('aria-busy'))
97
+ return;
98
+ button.setAttribute('aria-busy', 'true');
99
+ button.setAttribute('aria-disabled', 'true');
100
+ try {
101
+ const { reason } = await run();
102
+ if (reason === 'check' || reason === 'save') {
103
+ if (!form.getState().saveProblem)
104
+ announce(checkText(form.getState()));
105
+ focusFirstProblem();
137
106
  }
138
- widget.update({
139
- value: state.values[node.field],
140
- values: state.values,
141
- readonly: shown.readonly || locked,
142
- required: shown.required,
143
- invalid: !!message,
144
- describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
145
- });
146
- };
147
- updaters.push(update);
148
- return wrapper;
149
- }
150
- function hideWhen(element, id) {
151
- updaters.push(() => setHidden(element, form.node(id).invisible));
152
- }
153
- function buttonItem(node) {
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);
156
- button.addEventListener('click', async () => {
157
- if (node.confirm && !(await confirm(node.confirm)))
158
- return;
159
- await form.runAction(node.id);
160
- });
161
- hideWhen(button, node.id);
162
- return button;
163
- }
164
- function textItem(node) {
165
- const style = node.style ?? 'paragraph';
166
- const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
167
- spans(element, node.colspan);
168
- hideWhen(element, node.id);
169
- return element;
170
- }
171
- function slotItem(node) {
172
- const element = el('div', { class: 'fd-slot', 'data-slot': node.name, 'data-node': node.id });
173
- const cleanup = options.slots?.[node.name]?.(element, { form, name: node.name });
174
- if (cleanup)
175
- cleanups.push(cleanup);
176
- hideWhen(element, node.id);
177
- return element;
107
+ }
108
+ finally {
109
+ button.removeAttribute('aria-busy');
110
+ button.removeAttribute('aria-disabled');
111
+ }
178
112
  }
113
+ // ---- the parts -------------------------------------------------------
114
+ /** Folded sections, and how to open each: a problem inside one has to be seen. */
115
+ const folds = new Map();
116
+ /** Every part drawn again from the state of the page shown. */
117
+ const renderNow = () => render(form.getState());
118
+ /** Set once the viewer is on the page; set again when it is taken away. */
119
+ let mounted = false;
120
+ let gone = false;
179
121
  /** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
180
122
  function spans(element, colspan) {
181
123
  if (colspan)
182
124
  element.style.setProperty('--fd-span', String(colspan));
183
125
  }
126
+ /** Where the parts of a page, a tab or a step sit: one column, the page's labels. */
127
+ const top = (onPage) => ({ columns: 1, onPage, labels: page.look?.labels });
184
128
  /**
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).
129
+ * The parts of one page, drawn with its own form: the page shown, or a saved
130
+ * form placed in it. Inside, `form` and `page` are the scope's.
187
131
  */
188
- function grid(children, columns, place) {
189
- const box = el('div', { class: 'fd-grid' });
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));
132
+ function drawer(scope) {
133
+ const { form, page } = scope;
134
+ const uid = (id) => ownUid(scope.prefix + id);
135
+ /** A part's updater, handed the state of the part's own form. */
136
+ const watch = (update) => updaters.push(scope.root ? update : () => update(form.getState()));
137
+ /** A field with its label, help and messages. `labels` is where labels sit where it is put; the title's own fields take none. */
138
+ function fieldItem(node, labels) {
139
+ const def = page.fields[node.field];
140
+ const id = uid(node.id);
141
+ const labelsAt = labelPlace(node, def.type, labels);
142
+ 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 });
143
+ if (node.colspan)
144
+ wrapper.style.setProperty('--fd-span', String(node.colspan));
145
+ const labelText = node.label ?? def.label;
146
+ const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
147
+ // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
148
+ const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? drawIcon(doc, 'check') : null;
149
+ if (mark) {
150
+ mark.classList.add('fd-valid-mark');
151
+ label.append(mark);
152
+ }
153
+ const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
154
+ // 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.
155
+ if (labelsAt === 'hidden' && !node.placeholder) {
156
+ const box = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
157
+ box?.setAttribute('placeholder', labelText);
158
+ }
159
+ if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
160
+ // `for` stays: a custom field that puts the id on its own input is
161
+ // labelled natively. Only a wrapper with a role (a radio group, say) may
162
+ // carry a name; ARIA forbids naming a plain div or span.
163
+ // A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
164
+ const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
165
+ named?.setAttribute('aria-labelledby', label.id);
166
+ label.addEventListener('click', () => widget.focus());
167
+ }
168
+ const helpText = node.help ?? def.help;
169
+ const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
170
+ // Not an alert of its own: a refused save is announced once, naming every field to look at.
171
+ const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
172
+ // A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
173
+ const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
174
+ wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
175
+ if (widget.destroy)
176
+ cleanups.push(() => widget.destroy?.());
177
+ // An answer rule's warning waits until the person leaves the field: no advice
178
+ // mid-word. One already shown stays while they put it right, and goes once they have.
179
+ let typing = false;
180
+ let advice = '';
181
+ wrapper.addEventListener('focusin', () => (typing = true));
182
+ wrapper.addEventListener('focusout', (event) => {
183
+ if (wrapper.contains(event.relatedTarget))
184
+ return;
185
+ typing = false;
186
+ // Not at once: focus also leaves when a redraw removes the focused part
187
+ // (a deleted line's button), and that redraw must finish first.
188
+ queueMicrotask(() => update(form.getState()));
189
+ });
190
+ const update = (state) => {
191
+ const shown = form.node(node.id);
192
+ setHidden(wrapper, shown.invisible);
193
+ wrapper.classList.toggle('fd-required', shown.required);
194
+ const message = state.errors[node.field];
195
+ setHidden(error, !message);
196
+ setText(error, message ?? '');
197
+ const now = state.warnings[node.field] ?? '';
198
+ if (!typing || !now)
199
+ advice = now;
200
+ // An error shown says enough: the rule's warning gives way to it.
201
+ const warned = [message ? '' : advice, state.warning && state.warningField === node.field ? state.warning : ''].filter(Boolean).join(' ');
202
+ setHidden(warning, !warned);
203
+ setText(warning, warned);
204
+ if (mark) {
205
+ const value = state.values[node.field];
206
+ const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
207
+ const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
208
+ wrapper.classList.toggle('fd-valid', valid);
209
+ mark.toggleAttribute('hidden', !valid);
210
+ }
211
+ widget.update({
212
+ value: state.values[node.field],
213
+ values: state.values,
214
+ readonly: shown.readonly || locked || scope.locked(),
215
+ required: shown.required,
216
+ invalid: !!message,
217
+ describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
218
+ });
219
+ };
220
+ watch(update);
221
+ return wrapper;
222
+ }
223
+ function hideWhen(element, id) {
224
+ watch(() => setHidden(element, form.node(id).invisible));
225
+ }
226
+ function buttonItem(node) {
227
+ const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
228
+ spans(button, node.colspan);
229
+ // Its confirmation, then its steps: the form asks, through the viewer.
230
+ button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
231
+ hideWhen(button, node.id);
232
+ return button;
233
+ }
234
+ function textItem(node) {
235
+ const style = node.style ?? 'paragraph';
236
+ const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
237
+ spans(element, node.colspan);
238
+ hideWhen(element, node.id);
239
+ return element;
240
+ }
241
+ function slotItem(node) {
242
+ const element = el('div', { class: 'fd-slot', 'data-slot': node.name, 'data-node': node.id });
243
+ const cleanup = options.slots?.[node.name]?.(element, { form, name: node.name });
244
+ if (cleanup)
245
+ cleanups.push(cleanup);
246
+ hideWhen(element, node.id);
247
+ return element;
248
+ }
249
+ /**
250
+ * A grid of parts. With `columns` null, it has none of its own: it lays its
251
+ * parts on the columns of the grid round it (an arrangement on its tracks).
252
+ */
253
+ function grid(children, columns, place) {
254
+ const box = el('div', { class: 'fd-grid' });
255
+ if (columns !== null) {
256
+ box.style.setProperty('--fd-columns', String(wideColumns(columns)));
257
+ // Counts given for the narrower widths replace the skin's own stacking there.
258
+ if (typeof columns === 'object') {
259
+ for (const width of ['medium', 'narrow']) {
260
+ const count = columns[width];
261
+ if (count === undefined)
262
+ continue;
263
+ box.setAttribute(`data-columns-${width}`, String(count));
264
+ box.style.setProperty(`--fd-columns-${width}`, String(count));
265
+ }
200
266
  }
201
267
  }
268
+ box.append(...children.map((child) => item(child, place)));
269
+ return box;
202
270
  }
203
- box.append(...children.map((child) => item(child, place)));
204
- return box;
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 });
208
- /** Folded sections, and how to open each: a problem inside one has to be seen. */
209
- const folds = new Map();
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`);
223
- const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
224
- const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
225
- if (node.title && node.collapsible) {
226
- content.id = uid(`${node.id}-content`);
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));
228
- let open = node.collapsed !== true;
229
- const show = (next) => {
230
- open = next;
231
- toggle.setAttribute('aria-expanded', String(open));
232
- content.hidden = !open;
233
- if (description)
234
- description.hidden = !open;
235
- section.classList.toggle('fd-section-folded', !open);
271
+ function sectionItem(node, place) {
272
+ const plan = planSection(node, place);
273
+ // An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
274
+ const section = el(plan.arrangement ? 'div' : 'fieldset', {
275
+ class: 'fd-section',
276
+ 'data-node': node.id,
277
+ 'data-style': plan.style,
278
+ 'data-place': plan.at,
279
+ 'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
280
+ });
281
+ spans(section, node.colspan);
282
+ if (node.labelWidth)
283
+ section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
284
+ const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
285
+ const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
286
+ if (node.title && node.collapsible) {
287
+ content.id = uid(`${node.id}-content`);
288
+ 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));
289
+ let open = node.collapsed !== true;
290
+ const show = (next) => {
291
+ open = next;
292
+ toggle.setAttribute('aria-expanded', String(open));
293
+ content.hidden = !open;
294
+ if (description)
295
+ description.hidden = !open;
296
+ section.classList.toggle('fd-section-folded', !open);
297
+ };
298
+ toggle.addEventListener('click', () => show(!open));
299
+ folds.set(section, () => show(true));
300
+ show(open);
301
+ section.append(el('legend', { class: 'fd-section-title' }, toggle));
302
+ }
303
+ else if (node.title) {
304
+ section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
305
+ }
306
+ if (description)
307
+ section.append(description);
308
+ section.append(content);
309
+ hideWhen(section, node.id);
310
+ return section;
311
+ }
312
+ function tabsItem(node, place) {
313
+ const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
314
+ spans(box, node.colspan);
315
+ const list = el('div', { class: 'fd-tablist', role: 'tablist' });
316
+ // The tab someone picked. Until they pick, the first visible tab is open —
317
+ // a tab hidden while the record loads must not leave its neighbour open.
318
+ let picked = null;
319
+ let active = node.children[0].id;
320
+ /** A tab shown, by a person or a step: the page's steps for it run. */
321
+ const show = (tab) => {
322
+ picked = active = tab;
323
+ renderNow();
324
+ form.shown(tab);
236
325
  };
237
- toggle.addEventListener('click', () => show(!open));
238
- folds.set(section, () => show(true));
239
- show(open);
240
- section.append(el('legend', { class: 'fd-section-title' }, toggle));
241
- }
242
- else if (node.title) {
243
- section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
244
- }
245
- if (description)
246
- section.append(description);
247
- section.append(content);
248
- hideWhen(section, node.id);
249
- return section;
250
- }
251
- function tabsItem(node, place) {
252
- const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
253
- spans(box, node.colspan);
254
- const list = el('div', { class: 'fd-tablist', role: 'tablist' });
255
- // The tab someone picked. Until they pick, the first visible tab is open —
256
- // a tab hidden while the record loads must not leave its neighbour open.
257
- let picked = null;
258
- let active = node.children[0].id;
259
- const parts = node.children.map((tab) => {
260
- const tabId = uid(`${tab.id}-tab`);
261
- const panelId = uid(`${tab.id}-panel`);
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));
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 }));
265
- button.addEventListener('click', () => {
266
- picked = active = tab.id;
267
- render(form.getState());
326
+ const parts = node.children.map((tab) => {
327
+ const tabId = uid(`${tab.id}-tab`);
328
+ const panelId = uid(`${tab.id}-panel`);
329
+ 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));
330
+ // A tab's parts sit where the tabs do: on the page, or in the box round them.
331
+ 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 }));
332
+ button.addEventListener('click', () => show(tab.id));
333
+ if (!tabs.has(tab.id))
334
+ tabs.set(tab.id, () => !form.node(tab.id).invisible && (show(tab.id), true));
335
+ list.append(button);
336
+ return { tab, button, panel };
268
337
  });
269
- list.append(button);
270
- return { tab, button, panel };
271
- });
272
- list.addEventListener('keydown', (event) => {
273
- const shown = parts.filter((p) => !p.button.hidden);
274
- const at = shown.findIndex((p) => p.tab.id === active);
275
- const move = { ArrowRight: 1, ArrowLeft: -1, Home: -at, End: shown.length - 1 - at }[event.key];
276
- if (move === undefined || at === -1)
277
- return;
278
- event.preventDefault();
279
- const rtl = root.getAttribute('dir') === 'rtl' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft');
280
- const next = shown[(at + (rtl ? -move : move) + shown.length) % shown.length];
281
- picked = active = next.tab.id;
282
- render(form.getState());
283
- next.button.focus();
284
- });
285
- box.append(list, ...parts.map((p) => p.panel));
286
- updaters.push(() => {
287
- box.hidden = form.node(node.id).invisible;
288
- const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
289
- const keep = picked !== null && shown.some((p) => p.tab.id === picked);
290
- if (keep)
291
- active = picked;
292
- else if (shown.length)
293
- active = shown[0].tab.id;
294
- for (const p of parts) {
295
- const selected = p.tab.id === active;
296
- p.button.hidden = !shown.includes(p);
297
- p.button.setAttribute('aria-selected', String(selected));
298
- p.button.tabIndex = selected ? 0 : -1;
299
- p.panel.hidden = !selected;
338
+ list.addEventListener('keydown', (event) => {
339
+ const shown = parts.filter((p) => !p.button.hidden);
340
+ const at = shown.findIndex((p) => p.tab.id === active);
341
+ const move = { ArrowRight: 1, ArrowLeft: -1, Home: -at, End: shown.length - 1 - at }[event.key];
342
+ if (move === undefined || at === -1)
343
+ return;
344
+ event.preventDefault();
345
+ const rtl = root.getAttribute('dir') === 'rtl' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft');
346
+ const next = shown[(at + (rtl ? -move : move) + shown.length) % shown.length];
347
+ show(next.tab.id);
348
+ next.button.focus();
349
+ });
350
+ box.append(list, ...parts.map((p) => p.panel));
351
+ watch(() => {
352
+ box.hidden = form.node(node.id).invisible;
353
+ const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
354
+ const keep = picked !== null && shown.some((p) => p.tab.id === picked);
355
+ if (keep)
356
+ active = picked;
357
+ else if (shown.length)
358
+ active = shown[0].tab.id;
359
+ for (const p of parts) {
360
+ const selected = p.tab.id === active;
361
+ p.button.hidden = !shown.includes(p);
362
+ p.button.setAttribute('aria-selected', String(selected));
363
+ p.button.tabIndex = selected ? 0 : -1;
364
+ p.panel.hidden = !selected;
365
+ }
366
+ });
367
+ return box;
368
+ }
369
+ function item(node, place) {
370
+ switch (node.type) {
371
+ case 'field':
372
+ return fieldItem(node, place.labels);
373
+ case 'button':
374
+ return buttonItem(node);
375
+ case 'text':
376
+ return textItem(node);
377
+ case 'slot':
378
+ return slotItem(node);
379
+ case 'section':
380
+ return sectionItem(node, place);
381
+ case 'tabs':
382
+ return tabsItem(node, place);
383
+ case 'divider':
384
+ case 'spacer':
385
+ case 'image':
386
+ return blockItem(node);
387
+ case 'form':
388
+ return formItem(node, place);
300
389
  }
301
- });
302
- return box;
303
- }
304
- function item(node, place) {
305
- switch (node.type) {
306
- case 'field':
307
- return fieldItem(node, place.labels);
308
- case 'button':
309
- return buttonItem(node);
310
- case 'text':
311
- return textItem(node);
312
- case 'slot':
313
- return slotItem(node);
314
- case 'section':
315
- return sectionItem(node, place);
316
- case 'tabs':
317
- return tabsItem(node, place);
318
- case 'divider':
319
- case 'spacer':
320
- case 'image':
321
- return blockItem(node);
322
390
  }
391
+ /** A line across the row, empty room, or a picture: shown and hidden by its rule like any part. */
392
+ function blockItem(node) {
393
+ const element = node.type === 'divider' ? el('hr', { class: 'fd-divider', 'data-node': node.id })
394
+ : node.type === 'spacer' ? el('div', { class: 'fd-block fd-spacer', 'aria-hidden': 'true', 'data-node': node.id })
395
+ : figure(node);
396
+ if (node.type !== 'divider' && node.colspan)
397
+ element.style.setProperty('--fd-span', String(node.colspan));
398
+ hideWhen(element, node.id);
399
+ return element;
400
+ }
401
+ /** 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. */
402
+ function figure(node) {
403
+ const image = el('img', { class: 'fd-image', src: node.src, alt: node.alt, loading: 'lazy' });
404
+ const href = node.href && /^(https?:\/\/|mailto:)/i.test(node.href) ? node.href : undefined;
405
+ 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)] : []));
406
+ const width = typeof node.width === 'number' ? `${node.width}px` : node.width && { small: '160px', medium: '320px', large: '480px', full: '100%' }[node.width];
407
+ if (width)
408
+ element.style.setProperty('--fd-image-width', width);
409
+ return element;
410
+ }
411
+ /**
412
+ * A saved form placed here, drawn from the page the app gives for its id
413
+ * with a form of its own inside this one. A quiet placeholder while the
414
+ * page comes; words in its place when it cannot be found, would hold
415
+ * itself, or cannot sit in a form.
416
+ */
417
+ function formItem(node, place) {
418
+ const box = el('fieldset', {
419
+ class: 'fd-section fd-form-part',
420
+ 'data-node': node.id,
421
+ 'data-style': 'card',
422
+ 'data-on-page': place.onPage ? '' : undefined,
423
+ 'aria-busy': 'true',
424
+ });
425
+ spans(box, node.colspan);
426
+ const note = el('p', { class: 'fd-form-part-note' }, labels.loading);
427
+ box.append(note);
428
+ hideWhen(box, node.id);
429
+ const refuse = (words) => {
430
+ box.removeAttribute('aria-busy');
431
+ note.textContent = words;
432
+ note.classList.add('fd-form-part-problem');
433
+ };
434
+ const draw = (found) => {
435
+ if (gone)
436
+ return;
437
+ if (!found)
438
+ return refuse(fill(labels.formMissing, { page: node.page }));
439
+ const saved = shownAs(found);
440
+ const name = saved.title || found.id;
441
+ const at = scope.chain.findIndex((p) => p.id === found.id);
442
+ if (at !== -1)
443
+ return refuse(fill(labels.formInItself, { page: name, chain: [...scope.chain.slice(at).map((p) => p.title), name].join(' → ') }));
444
+ if (found.layout.type !== 'sections' && found.layout.type !== 'tabs')
445
+ return refuse(fill(labels.formNotPlaceable, { page: name }));
446
+ const checked = checkPage(saved);
447
+ let inner;
448
+ try {
449
+ if (!checked.ok)
450
+ throw new Error(checked.issues.map((i) => `${i.path}: ${i.message}`).join('; '));
451
+ inner = form.embed(node.id, saved);
452
+ }
453
+ catch (error) {
454
+ console.warn(`Fieldia: the saved form "${found.id}" cannot be shown: ${error.message}`);
455
+ return refuse(fill(labels.formBroken, { page: name }));
456
+ }
457
+ const title = node.title ?? saved.title ?? '';
458
+ box.setAttribute('data-title', title);
459
+ if (title)
460
+ box.prepend(el('legend', { class: 'fd-section-title' }, ...withIcon(undefined, title)));
461
+ const own = drawer({
462
+ root: false,
463
+ form: inner,
464
+ page: saved,
465
+ prefix: `${scope.prefix}${node.id}-`,
466
+ chain: [...scope.chain, { id: found.id, title: name }],
467
+ locked: () => scope.locked() || form.node(node.id).readonly,
468
+ path: `${scope.path}${node.name}.`,
469
+ });
470
+ const layout = saved.layout;
471
+ // Its parts sit in the part's box: a card there draws none of its own.
472
+ note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels }));
473
+ box.removeAttribute('aria-busy');
474
+ cleanups.push(inner.subscribe(renderNow));
475
+ if (mounted)
476
+ renderNow();
477
+ };
478
+ let asked;
479
+ try {
480
+ asked = findPage(options, node.version === undefined ? { id: node.page } : { id: node.page, version: node.version });
481
+ }
482
+ catch {
483
+ asked = null;
484
+ }
485
+ if (asked instanceof Promise)
486
+ asked.then(draw, () => draw(null));
487
+ else
488
+ draw(asked);
489
+ return box;
490
+ }
491
+ return { item, grid, fieldItem, buttonItem, hideWhen, slotItem };
323
492
  }
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
- }
493
+ const { item, grid, fieldItem, buttonItem, hideWhen, slotItem } = drawer({
494
+ root: true,
495
+ form,
496
+ page,
497
+ prefix: '',
498
+ chain: [{ id: page.id, title: page.title || page.id }],
499
+ locked: () => false,
500
+ path: '',
501
+ });
344
502
  // ---- shared chrome ----------------------------------------------------------
345
503
  /** The whole form locked: every field read-only, no Save; buttons still run. */
346
504
  let locked = options.readonly === true;
@@ -377,18 +535,27 @@ export function mountViewer(host, options) {
377
535
  retry.addEventListener('click', () => void submitOrSave());
378
536
  const status = el('div', { class: 'fd-status-box' }, statusText, retry);
379
537
  const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
380
- /** "Not saved. Check: Email, Phone": the fields with a problem, in the order they show. */
538
+ /**
539
+ * "Not saved. Check: Email, Phone": the fields with a problem, in the order
540
+ * they show; one inside a saved form with that form's title, "Street (Home address)".
541
+ */
381
542
  function checkText(state) {
382
- const names = [];
543
+ const fieldsShown = [...root.querySelectorAll('.fd-field[data-path]')];
544
+ const found = [];
383
545
  for (const key of Object.keys(state.errors)) {
384
- const field = key.split('.')[0];
385
- const shown = root.querySelector(`.fd-field[data-field="${field}"]:not([hidden]) > .fd-label`);
386
- const name = shown?.firstChild?.textContent ?? page.fields[field]?.label ?? field;
387
- if (!names.includes(name))
388
- names.push(name);
389
- }
390
- const inOrder = [...root.querySelectorAll('.fd-field[data-field] > .fd-label')].map((label) => label.firstChild?.textContent ?? '');
391
- names.sort((a, b) => (inOrder.indexOf(a) + 1 || Infinity) - (inOrder.indexOf(b) + 1 || Infinity));
546
+ // The field itself, or — for a line's — the table it is in.
547
+ let shown;
548
+ for (let path = key; path && !shown; path = path.slice(0, Math.max(0, path.lastIndexOf('.')))) {
549
+ shown = fieldsShown.find((field) => field.getAttribute('data-path') === path && !field.hidden);
550
+ }
551
+ const first = key.split('.')[0];
552
+ const label = shown?.querySelector(':scope > .fd-label')?.firstChild?.textContent ?? page.fields[first]?.label ?? first;
553
+ const within = shown?.closest('.fd-form-part')?.getAttribute('data-title');
554
+ const name = within ? `${label} (${within})` : label;
555
+ if (!found.some((f) => f.name === name))
556
+ found.push({ name, at: shown ? fieldsShown.indexOf(shown) : Infinity });
557
+ }
558
+ const names = found.sort((a, b) => a.at - b.at).map((f) => f.name);
392
559
  return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
393
560
  }
394
561
  let quietTimer;
@@ -653,7 +820,7 @@ export function mountViewer(host, options) {
653
820
  const icon = drawIcon(doc, stat.icon, options.icons);
654
821
  const words = el('span', { class: 'fd-stat-words' }, value, el('span', { class: 'fd-stat-label' }, stat.label));
655
822
  const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, ...(icon ? [icon, words] : [words]));
656
- button.addEventListener('click', () => void form.runAction(stat.id));
823
+ button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
657
824
  updaters.push((state) => {
658
825
  button.hidden = form.node(stat.id).invisible;
659
826
  const count = stat.field ? state.values[stat.field] : null;
@@ -759,7 +926,7 @@ export function mountViewer(host, options) {
759
926
  labels,
760
927
  locale,
761
928
  fill,
762
- confirm,
929
+ press,
763
930
  withIcon,
764
931
  uid,
765
932
  icons: options.icons,
@@ -868,9 +1035,13 @@ export function mountViewer(host, options) {
868
1035
  };
869
1036
  cancel.addEventListener('click', () => close(false));
870
1037
  ok.addEventListener('click', () => close(true));
1038
+ // Its keys are its own: Escape answers No here, and closes no dialog or panel under it.
871
1039
  backdrop.addEventListener('keydown', (event) => {
872
- if (event.key === 'Escape')
1040
+ event.stopPropagation();
1041
+ if (event.key === 'Escape') {
1042
+ event.preventDefault();
873
1043
  close(false);
1044
+ }
874
1045
  else
875
1046
  keepTabIn(dialog, event);
876
1047
  });
@@ -885,7 +1056,13 @@ export function mountViewer(host, options) {
885
1056
  }
886
1057
  host.append(root);
887
1058
  const unsubscribe = form.subscribe(render);
1059
+ // 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.
1060
+ cleanups.push(form.on('run', ({ result }) => {
1061
+ if (result.reason === 'cannot' || (result.reason === 'app' && result.error !== undefined))
1062
+ actionHost.say(result.message ?? '', result.reason === 'app' ? 'danger' : 'warning');
1063
+ }));
888
1064
  render(form.getState());
1065
+ mounted = true;
889
1066
  if (form.getState().status === 'idle')
890
1067
  void form.load();
891
1068
  return {
@@ -912,7 +1089,15 @@ export function mountViewer(host, options) {
912
1089
  render(form.getState());
913
1090
  },
914
1091
  isReadonly: () => locked,
1092
+ on: (event, listener) => {
1093
+ const off = form.on(event, listener);
1094
+ cleanups.push(off);
1095
+ return off;
1096
+ },
1097
+ setValues: (values) => form.setValues(values),
1098
+ run: (steps, context) => form.run(steps, context),
915
1099
  destroy() {
1100
+ gone = true;
916
1101
  unsubscribe();
917
1102
  for (const cleanup of cleanups)
918
1103
  cleanup();