@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.
- package/README.md +145 -1
- package/bundle/fieldia.ar.js +3 -0
- package/bundle/fieldia.ar.js.map +7 -0
- package/bundle/fieldia.de.js +3 -0
- package/bundle/fieldia.de.js.map +7 -0
- package/bundle/fieldia.fr.js +3 -0
- package/bundle/fieldia.fr.js.map +7 -0
- package/bundle/fieldia.js +4 -4
- package/bundle/fieldia.js.map +4 -4
- package/package.json +6 -3
- package/src/index.d.mts +4 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +12 -0
- package/src/index.js.map +1 -1
- package/src/index.mjs +4 -0
- package/src/index.mjs.map +1 -1
- package/src/lib/add-language.d.mts +20 -0
- package/src/lib/add-language.d.ts +20 -0
- package/src/lib/add-language.js +19 -0
- package/src/lib/add-language.js.map +1 -0
- package/src/lib/add-language.mjs +16 -0
- package/src/lib/add-language.mjs.map +1 -0
- package/src/lib/dialog.d.mts +35 -3
- package/src/lib/dialog.d.ts +35 -3
- package/src/lib/dialog.js +109 -47
- package/src/lib/dialog.js.map +1 -1
- package/src/lib/dialog.mjs +107 -47
- package/src/lib/dialog.mjs.map +1 -1
- package/src/lib/dom.d.mts +10 -0
- package/src/lib/dom.d.ts +10 -0
- package/src/lib/dom.js +26 -0
- package/src/lib/dom.js.map +1 -1
- package/src/lib/dom.mjs +23 -1
- package/src/lib/dom.mjs.map +1 -1
- package/src/lib/focus-trap.d.mts +2 -0
- package/src/lib/focus-trap.d.ts +2 -0
- package/src/lib/focus-trap.js +7 -0
- package/src/lib/focus-trap.js.map +1 -0
- package/src/lib/focus-trap.mjs +3 -0
- package/src/lib/focus-trap.mjs.map +1 -0
- package/src/lib/labels.d.mts +27 -0
- package/src/lib/labels.d.ts +27 -0
- package/src/lib/labels.js +109 -277
- package/src/lib/labels.js.map +1 -1
- package/src/lib/labels.mjs +108 -277
- package/src/lib/labels.mjs.map +1 -1
- package/src/lib/list-styles.d.mts +1 -1
- package/src/lib/list-styles.d.ts +1 -1
- package/src/lib/list-styles.js +3 -2
- package/src/lib/list-styles.js.map +1 -1
- package/src/lib/list-styles.mjs +3 -2
- package/src/lib/list-styles.mjs.map +1 -1
- package/src/lib/list.d.mts +3 -2
- package/src/lib/list.d.ts +3 -2
- package/src/lib/list.js +5 -5
- package/src/lib/list.js.map +1 -1
- package/src/lib/list.mjs +5 -5
- package/src/lib/list.mjs.map +1 -1
- package/src/lib/locales/all.d.mts +8 -0
- package/src/lib/locales/all.d.ts +8 -0
- package/src/lib/locales/all.js +13 -0
- package/src/lib/locales/all.js.map +1 -0
- package/src/lib/locales/all.mjs +10 -0
- package/src/lib/locales/all.mjs.map +1 -0
- package/src/lib/locales/ar.d.mts +3 -0
- package/src/lib/locales/ar.d.ts +3 -0
- package/src/lib/locales/ar.js +80 -0
- package/src/lib/locales/ar.js.map +1 -0
- package/src/lib/locales/ar.mjs +77 -0
- package/src/lib/locales/ar.mjs.map +1 -0
- package/src/lib/locales/de.d.mts +3 -0
- package/src/lib/locales/de.d.ts +3 -0
- package/src/lib/locales/de.js +80 -0
- package/src/lib/locales/de.js.map +1 -0
- package/src/lib/locales/de.mjs +77 -0
- package/src/lib/locales/de.mjs.map +1 -0
- package/src/lib/locales/fr.d.mts +3 -0
- package/src/lib/locales/fr.d.ts +3 -0
- package/src/lib/locales/fr.js +80 -0
- package/src/lib/locales/fr.js.map +1 -0
- package/src/lib/locales/fr.mjs +77 -0
- package/src/lib/locales/fr.mjs.map +1 -0
- package/src/lib/look.d.mts +25 -0
- package/src/lib/look.d.ts +25 -0
- package/src/lib/look.js +141 -0
- package/src/lib/look.js.map +1 -0
- package/src/lib/look.mjs +136 -0
- package/src/lib/look.mjs.map +1 -0
- package/src/lib/open.d.mts +24 -0
- package/src/lib/open.d.ts +24 -0
- package/src/lib/open.js +81 -0
- package/src/lib/open.js.map +1 -0
- package/src/lib/open.mjs +78 -0
- package/src/lib/open.mjs.map +1 -0
- package/src/lib/place.d.mts +35 -0
- package/src/lib/place.d.ts +35 -0
- package/src/lib/place.js +37 -0
- package/src/lib/place.js.map +1 -0
- package/src/lib/place.mjs +33 -0
- package/src/lib/place.mjs.map +1 -0
- package/src/lib/related.d.mts +19 -0
- package/src/lib/related.d.ts +19 -0
- package/src/lib/related.js +17 -3
- package/src/lib/related.js.map +1 -1
- package/src/lib/related.mjs +16 -3
- package/src/lib/related.mjs.map +1 -1
- package/src/lib/say.d.mts +9 -0
- package/src/lib/say.d.ts +9 -0
- package/src/lib/say.js +45 -0
- package/src/lib/say.js.map +1 -0
- package/src/lib/say.mjs +42 -0
- package/src/lib/say.mjs.map +1 -0
- package/src/lib/viewer.d.mts +43 -7
- package/src/lib/viewer.d.ts +43 -7
- package/src/lib/viewer.js +513 -225
- package/src/lib/viewer.js.map +1 -1
- package/src/lib/viewer.mjs +516 -228
- package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.mjs
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { createForm, checkPage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
|
|
1
|
+
import { createForm, checkPage, isRightToLeft, localizePage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
|
|
2
2
|
import { browserPreferences, createWidget, drawIcon, installStyles, WIDGET_LABELS } from '@fieldia/widgets';
|
|
3
|
-
import { pageDialogs } from './related.mjs';
|
|
3
|
+
import { findPage, pageDialogs } from './related.mjs';
|
|
4
4
|
import { listView } from './list.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { applyLook } from './look.mjs';
|
|
6
|
+
import { labelPlace, planSection } from './place.mjs';
|
|
7
|
+
import { setAttr, setHidden, setText } from './dom.mjs';
|
|
8
|
+
import { openPage } from './open.mjs';
|
|
9
|
+
import { sayer } from './say.mjs';
|
|
10
|
+
import { ownLocale, VIEWER_LABELS } from './labels.mjs';
|
|
11
|
+
import { keepTabIn } from './focus-trap.mjs';
|
|
6
12
|
export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
|
|
7
13
|
let mounts = 0;
|
|
8
14
|
/**
|
|
@@ -17,19 +23,44 @@ export function mountViewer(host, options) {
|
|
|
17
23
|
if (!checked.ok) {
|
|
18
24
|
throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
|
|
19
25
|
}
|
|
20
|
-
|
|
21
|
-
const
|
|
26
|
+
// The page's own words in its language first; the app's translator over what is left.
|
|
27
|
+
const tag = options.locale ?? checked.page.language;
|
|
28
|
+
const localized = tag ? localizePage(checked.page, tag) : checked.page;
|
|
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
|
+
};
|
|
35
|
+
const locale = ownLocale(tag);
|
|
22
36
|
const preferences = options.preferences ?? browserPreferences();
|
|
23
37
|
const dialogs = pageDialogs(options);
|
|
24
38
|
const ownsForm = !options.form;
|
|
25
|
-
const form = options.form ?? createForm({ ...options, page, messages: options.messages ?? MESSAGES[locale] });
|
|
26
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] });
|
|
27
57
|
const widgetLabels = WIDGET_LABELS[locale];
|
|
28
|
-
const dir = options.dir ?? (
|
|
58
|
+
const dir = options.dir ?? (tag && isRightToLeft(tag) ? 'rtl' : undefined);
|
|
29
59
|
const prefix = `fd${++mounts}`;
|
|
30
60
|
const updaters = [];
|
|
31
61
|
const cleanups = [];
|
|
32
|
-
const
|
|
62
|
+
const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
63
|
+
const uid = ownUid;
|
|
33
64
|
const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
|
|
34
65
|
installStyles(doc);
|
|
35
66
|
function el(tag, attrs = {}, ...children) {
|
|
@@ -40,217 +71,434 @@ export function mountViewer(host, options) {
|
|
|
40
71
|
node.append(...children);
|
|
41
72
|
return node;
|
|
42
73
|
}
|
|
43
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Words with the icon a page names before them, when there is such an icon.
|
|
76
|
+
* The words sit in a <bdi>: left as written on a page of the other direction,
|
|
77
|
+
* they still run their own way, the icon still before them.
|
|
78
|
+
*/
|
|
44
79
|
function withIcon(name, text) {
|
|
45
80
|
const icon = drawIcon(doc, name, options.icons);
|
|
46
|
-
|
|
81
|
+
const words = el('bdi', {}, text);
|
|
82
|
+
return icon ? [icon, words] : [words];
|
|
47
83
|
}
|
|
48
|
-
const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang:
|
|
84
|
+
const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: tag, 'data-max-width': page.maxWidth });
|
|
85
|
+
applyLook(root, page.look);
|
|
49
86
|
const confirm = options.confirm ?? dialogConfirm;
|
|
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();
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
finally {
|
|
109
|
+
button.removeAttribute('aria-busy');
|
|
110
|
+
button.removeAttribute('aria-disabled');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
50
113
|
// ---- the parts -------------------------------------------------------
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const message = state.errors[node.field];
|
|
87
|
-
error.hidden = !message;
|
|
88
|
-
error.textContent = message ?? '';
|
|
89
|
-
const warned = state.warning && state.warningField === node.field ? state.warning : '';
|
|
90
|
-
warning.hidden = !warned;
|
|
91
|
-
warning.textContent = warned;
|
|
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;
|
|
121
|
+
/** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
|
|
122
|
+
function spans(element, colspan) {
|
|
123
|
+
if (colspan)
|
|
124
|
+
element.style.setProperty('--fd-span', String(colspan));
|
|
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 });
|
|
128
|
+
/**
|
|
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.
|
|
131
|
+
*/
|
|
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;
|
|
92
149
|
if (mark) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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());
|
|
98
167
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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()));
|
|
106
189
|
});
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
266
|
+
}
|
|
150
267
|
}
|
|
268
|
+
box.append(...children.map((child) => item(child, place)));
|
|
269
|
+
return box;
|
|
151
270
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
content.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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);
|
|
172
325
|
};
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
hideWhen(section, node.id);
|
|
185
|
-
return section;
|
|
186
|
-
}
|
|
187
|
-
function tabsItem(node) {
|
|
188
|
-
const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
|
|
189
|
-
const list = el('div', { class: 'fd-tablist', role: 'tablist' });
|
|
190
|
-
// The tab someone picked. Until they pick, the first visible tab is open —
|
|
191
|
-
// a tab hidden while the record loads must not leave its neighbour open.
|
|
192
|
-
let picked = null;
|
|
193
|
-
let active = node.children[0].id;
|
|
194
|
-
const parts = node.children.map((tab) => {
|
|
195
|
-
const tabId = uid(`${tab.id}-tab`);
|
|
196
|
-
const panelId = uid(`${tab.id}-panel`);
|
|
197
|
-
const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, ...withIcon(tab.icon, tab.label));
|
|
198
|
-
const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children));
|
|
199
|
-
button.addEventListener('click', () => {
|
|
200
|
-
picked = active = tab.id;
|
|
201
|
-
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 };
|
|
202
337
|
});
|
|
203
|
-
list.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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);
|
|
234
389
|
}
|
|
235
|
-
});
|
|
236
|
-
return box;
|
|
237
|
-
}
|
|
238
|
-
function item(node) {
|
|
239
|
-
switch (node.type) {
|
|
240
|
-
case 'field':
|
|
241
|
-
return fieldItem(node);
|
|
242
|
-
case 'button':
|
|
243
|
-
return buttonItem(node);
|
|
244
|
-
case 'text':
|
|
245
|
-
return textItem(node);
|
|
246
|
-
case 'slot':
|
|
247
|
-
return slotItem(node);
|
|
248
|
-
case 'section':
|
|
249
|
-
return sectionItem(node);
|
|
250
|
-
case 'tabs':
|
|
251
|
-
return tabsItem(node);
|
|
252
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 };
|
|
253
492
|
}
|
|
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
|
+
});
|
|
254
502
|
// ---- shared chrome ----------------------------------------------------------
|
|
255
503
|
/** The whole form locked: every field read-only, no Save; buttons still run. */
|
|
256
504
|
let locked = options.readonly === true;
|
|
@@ -287,18 +535,27 @@ export function mountViewer(host, options) {
|
|
|
287
535
|
retry.addEventListener('click', () => void submitOrSave());
|
|
288
536
|
const status = el('div', { class: 'fd-status-box' }, statusText, retry);
|
|
289
537
|
const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
|
|
290
|
-
/**
|
|
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
|
+
*/
|
|
291
542
|
function checkText(state) {
|
|
292
|
-
const
|
|
543
|
+
const fieldsShown = [...root.querySelectorAll('.fd-field[data-path]')];
|
|
544
|
+
const found = [];
|
|
293
545
|
for (const key of Object.keys(state.errors)) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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);
|
|
302
559
|
return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
|
|
303
560
|
}
|
|
304
561
|
let quietTimer;
|
|
@@ -310,8 +567,10 @@ export function mountViewer(host, options) {
|
|
|
310
567
|
: state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
|
|
311
568
|
: state.status === 'error' ? state.error ?? ''
|
|
312
569
|
: '';
|
|
313
|
-
|
|
314
|
-
|
|
570
|
+
// A problem is said once, at once, by the announcer (or the banner, or a dialog): the status shows it and keeps quiet.
|
|
571
|
+
setAttr(statusText, 'aria-live', state.status === 'error' && problem ? 'off' : 'polite');
|
|
572
|
+
setText(statusText, text);
|
|
573
|
+
setHidden(retry, !(state.status === 'error' && problem?.kind === 'other'));
|
|
315
574
|
statusText.classList.toggle('fd-status-error', state.status === 'error');
|
|
316
575
|
statusText.classList.toggle('fd-status-saved', state.status === 'saved');
|
|
317
576
|
// A toast shows while there is something to say, and lets "Saved" go after a moment.
|
|
@@ -356,7 +615,7 @@ export function mountViewer(host, options) {
|
|
|
356
615
|
discardDraft.addEventListener('click', () => form.discardDraft());
|
|
357
616
|
draft.append(draftText, restore, discardDraft);
|
|
358
617
|
updaters.push((state) => {
|
|
359
|
-
draft
|
|
618
|
+
setHidden(draft, !state.draft);
|
|
360
619
|
if (state.draft)
|
|
361
620
|
draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
|
|
362
621
|
});
|
|
@@ -408,7 +667,7 @@ export function mountViewer(host, options) {
|
|
|
408
667
|
}
|
|
409
668
|
// ---- the four page layouts ---------------------------------------------------
|
|
410
669
|
function sectionsLayout(node) {
|
|
411
|
-
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map(item));
|
|
670
|
+
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
|
|
412
671
|
const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
|
|
413
672
|
const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
|
|
414
673
|
if (page.data.kind === 'record') {
|
|
@@ -418,12 +677,12 @@ export function mountViewer(host, options) {
|
|
|
418
677
|
actions.insertBefore(discard, action);
|
|
419
678
|
updaters.push((state) => {
|
|
420
679
|
const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
|
|
421
|
-
action
|
|
422
|
-
discard
|
|
680
|
+
setHidden(action, !dirty);
|
|
681
|
+
setHidden(discard, !dirty);
|
|
423
682
|
});
|
|
424
683
|
}
|
|
425
684
|
else
|
|
426
|
-
updaters.push(() => (action
|
|
685
|
+
updaters.push(() => setHidden(action, locked));
|
|
427
686
|
if (editSwitch)
|
|
428
687
|
actions.prepend(editSwitch);
|
|
429
688
|
if (options.showActions === false)
|
|
@@ -461,7 +720,8 @@ export function mountViewer(host, options) {
|
|
|
461
720
|
panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, ...withIcon(step.icon, step.label)));
|
|
462
721
|
if (step.description)
|
|
463
722
|
panel.append(el('p', { class: 'fd-section-description' }, step.description));
|
|
464
|
-
|
|
723
|
+
// The step is the box: a card in it draws none of its own.
|
|
724
|
+
panel.append(grid(step.children, 1, top(false)));
|
|
465
725
|
box.append(panel);
|
|
466
726
|
return { step, panel };
|
|
467
727
|
});
|
|
@@ -560,7 +820,7 @@ export function mountViewer(host, options) {
|
|
|
560
820
|
const icon = drawIcon(doc, stat.icon, options.icons);
|
|
561
821
|
const words = el('span', { class: 'fd-stat-words' }, value, el('span', { class: 'fd-stat-label' }, stat.label));
|
|
562
822
|
const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, ...(icon ? [icon, words] : [words]));
|
|
563
|
-
button.addEventListener('click', () => void form.runAction(stat.id));
|
|
823
|
+
button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
|
|
564
824
|
updaters.push((state) => {
|
|
565
825
|
button.hidden = form.node(stat.id).invisible;
|
|
566
826
|
const count = stat.field ? state.values[stat.field] : null;
|
|
@@ -604,12 +864,12 @@ export function mountViewer(host, options) {
|
|
|
604
864
|
if (node.title) {
|
|
605
865
|
const title = el('div', { class: 'fd-title' });
|
|
606
866
|
if (node.title.above?.length)
|
|
607
|
-
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map(fieldItem)));
|
|
867
|
+
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
|
|
608
868
|
title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
|
|
609
869
|
if (node.title.subtitleField)
|
|
610
870
|
title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
|
|
611
871
|
if (node.title.below?.length)
|
|
612
|
-
title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map(fieldItem)));
|
|
872
|
+
title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map((part) => fieldItem(part))));
|
|
613
873
|
const row = el('div', { class: 'fd-title-row' }, title);
|
|
614
874
|
if (node.title.avatarField) {
|
|
615
875
|
const avatar = fieldItem({ type: 'field', id: '#avatar', field: node.title.avatarField });
|
|
@@ -620,7 +880,7 @@ export function mountViewer(host, options) {
|
|
|
620
880
|
}
|
|
621
881
|
if (node.statusbar && underTitle)
|
|
622
882
|
card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
|
|
623
|
-
card.append(grid(node.children));
|
|
883
|
+
card.append(grid(node.children, 1, top(false)));
|
|
624
884
|
if (atFoot && options.showActions !== false) {
|
|
625
885
|
card.append(foot);
|
|
626
886
|
updaters.push((state) => {
|
|
@@ -666,7 +926,7 @@ export function mountViewer(host, options) {
|
|
|
666
926
|
labels,
|
|
667
927
|
locale,
|
|
668
928
|
fill,
|
|
669
|
-
|
|
929
|
+
press,
|
|
670
930
|
withIcon,
|
|
671
931
|
uid,
|
|
672
932
|
icons: options.icons,
|
|
@@ -763,15 +1023,27 @@ export function mountViewer(host, options) {
|
|
|
763
1023
|
const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
|
|
764
1024
|
const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, ...(withCancel ? [cancel, ok] : [ok])));
|
|
765
1025
|
const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
|
|
1026
|
+
const doc = root.ownerDocument;
|
|
1027
|
+
const opener = doc.activeElement;
|
|
766
1028
|
const close = (answer) => {
|
|
767
1029
|
backdrop.remove();
|
|
1030
|
+
// Back to what had focus when it asked; or, when that let go of it — Save hides while it saves — to Save, shown again.
|
|
1031
|
+
const shown = (e) => !!e && e !== doc.body && e.isConnected && !e.closest('[hidden]');
|
|
1032
|
+
const back = shown(opener) ? opener : root.querySelector('button[type="submit"]:not([hidden])');
|
|
1033
|
+
back?.focus();
|
|
768
1034
|
resolve(answer);
|
|
769
1035
|
};
|
|
770
1036
|
cancel.addEventListener('click', () => close(false));
|
|
771
1037
|
ok.addEventListener('click', () => close(true));
|
|
1038
|
+
// Its keys are its own: Escape answers No here, and closes no dialog or panel under it.
|
|
772
1039
|
backdrop.addEventListener('keydown', (event) => {
|
|
773
|
-
|
|
1040
|
+
event.stopPropagation();
|
|
1041
|
+
if (event.key === 'Escape') {
|
|
1042
|
+
event.preventDefault();
|
|
774
1043
|
close(false);
|
|
1044
|
+
}
|
|
1045
|
+
else
|
|
1046
|
+
keepTabIn(dialog, event);
|
|
775
1047
|
});
|
|
776
1048
|
root.append(backdrop);
|
|
777
1049
|
ok.focus();
|
|
@@ -784,7 +1056,13 @@ export function mountViewer(host, options) {
|
|
|
784
1056
|
}
|
|
785
1057
|
host.append(root);
|
|
786
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
|
+
}));
|
|
787
1064
|
render(form.getState());
|
|
1065
|
+
mounted = true;
|
|
788
1066
|
if (form.getState().status === 'idle')
|
|
789
1067
|
void form.load();
|
|
790
1068
|
return {
|
|
@@ -811,7 +1089,15 @@ export function mountViewer(host, options) {
|
|
|
811
1089
|
render(form.getState());
|
|
812
1090
|
},
|
|
813
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),
|
|
814
1099
|
destroy() {
|
|
1100
|
+
gone = true;
|
|
815
1101
|
unsubscribe();
|
|
816
1102
|
for (const cleanup of cleanups)
|
|
817
1103
|
cleanup();
|
|
@@ -821,6 +1107,8 @@ export function mountViewer(host, options) {
|
|
|
821
1107
|
},
|
|
822
1108
|
};
|
|
823
1109
|
}
|
|
1110
|
+
/** The boxes a label can be shown in, as the words of an empty box. */
|
|
1111
|
+
const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
|
|
824
1112
|
/** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
|
|
825
1113
|
const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
|
|
826
1114
|
//# sourceMappingURL=viewer.mjs.map
|