@fieldia/viewer 0.10.0 → 0.12.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 +126 -1
- package/bundle/fieldia.ar.js +2 -2
- package/bundle/fieldia.ar.js.map +2 -2
- package/bundle/fieldia.de.js +2 -2
- package/bundle/fieldia.de.js.map +2 -2
- package/bundle/fieldia.fr.js +2 -2
- package/bundle/fieldia.fr.js.map +2 -2
- package/bundle/fieldia.js +6 -4
- package/bundle/fieldia.js.map +4 -4
- package/package.json +3 -3
- package/src/lib/attachment-preview.d.mts +27 -0
- package/src/lib/attachment-preview.d.ts +27 -0
- package/src/lib/attachment-preview.js +89 -0
- package/src/lib/attachment-preview.js.map +1 -0
- package/src/lib/attachment-preview.mjs +86 -0
- package/src/lib/attachment-preview.mjs.map +1 -0
- package/src/lib/dialog.d.mts +31 -2
- package/src/lib/dialog.d.ts +31 -2
- package/src/lib/dialog.js +158 -30
- package/src/lib/dialog.js.map +1 -1
- package/src/lib/dialog.mjs +155 -30
- package/src/lib/dialog.mjs.map +1 -1
- package/src/lib/hotkeys.d.mts +11 -0
- package/src/lib/hotkeys.d.ts +11 -0
- package/src/lib/hotkeys.js +34 -0
- package/src/lib/hotkeys.js.map +1 -0
- package/src/lib/hotkeys.mjs +30 -0
- package/src/lib/hotkeys.mjs.map +1 -0
- package/src/lib/labels.d.mts +39 -0
- package/src/lib/labels.d.ts +39 -0
- package/src/lib/labels.js +23 -0
- package/src/lib/labels.js.map +1 -1
- package/src/lib/labels.mjs +23 -0
- package/src/lib/labels.mjs.map +1 -1
- package/src/lib/list.d.mts +5 -2
- package/src/lib/list.d.ts +5 -2
- package/src/lib/list.js +6 -6
- package/src/lib/list.js.map +1 -1
- package/src/lib/list.mjs +6 -6
- package/src/lib/list.mjs.map +1 -1
- package/src/lib/locales/ar.js +23 -0
- package/src/lib/locales/ar.js.map +1 -1
- package/src/lib/locales/ar.mjs +23 -0
- package/src/lib/locales/ar.mjs.map +1 -1
- package/src/lib/locales/de.js +23 -0
- package/src/lib/locales/de.js.map +1 -1
- package/src/lib/locales/de.mjs +23 -0
- package/src/lib/locales/de.mjs.map +1 -1
- package/src/lib/locales/fr.js +23 -0
- package/src/lib/locales/fr.js.map +1 -1
- package/src/lib/locales/fr.mjs +23 -0
- package/src/lib/locales/fr.mjs.map +1 -1
- package/src/lib/look.d.mts +7 -3
- package/src/lib/look.d.ts +7 -3
- package/src/lib/look.js +72 -15
- package/src/lib/look.js.map +1 -1
- package/src/lib/look.mjs +72 -15
- package/src/lib/look.mjs.map +1 -1
- package/src/lib/open.d.mts +30 -0
- package/src/lib/open.d.ts +30 -0
- package/src/lib/open.js +92 -0
- package/src/lib/open.js.map +1 -0
- package/src/lib/open.mjs +89 -0
- package/src/lib/open.mjs.map +1 -0
- package/src/lib/place.d.mts +2 -0
- package/src/lib/place.d.ts +2 -0
- package/src/lib/place.js.map +1 -1
- package/src/lib/place.mjs.map +1 -1
- package/src/lib/record-bar.d.mts +56 -0
- package/src/lib/record-bar.d.ts +56 -0
- package/src/lib/record-bar.js +237 -0
- package/src/lib/record-bar.js.map +1 -0
- package/src/lib/record-bar.mjs +234 -0
- package/src/lib/record-bar.mjs.map +1 -0
- package/src/lib/record-styles.d.mts +9 -0
- package/src/lib/record-styles.d.ts +9 -0
- package/src/lib/record-styles.js +83 -0
- package/src/lib/record-styles.js.map +1 -0
- package/src/lib/record-styles.mjs +79 -0
- package/src/lib/record-styles.mjs.map +1 -0
- package/src/lib/related.d.mts +26 -3
- package/src/lib/related.d.ts +26 -3
- package/src/lib/related.js +37 -10
- package/src/lib/related.js.map +1 -1
- package/src/lib/related.mjs +36 -10
- package/src/lib/related.mjs.map +1 -1
- package/src/lib/say.d.mts +11 -0
- package/src/lib/say.d.ts +11 -0
- package/src/lib/say.js +61 -0
- package/src/lib/say.js.map +1 -0
- package/src/lib/say.mjs +58 -0
- package/src/lib/say.mjs.map +1 -0
- package/src/lib/tab-strip.d.mts +17 -0
- package/src/lib/tab-strip.d.ts +17 -0
- package/src/lib/tab-strip.js +51 -0
- package/src/lib/tab-strip.js.map +1 -0
- package/src/lib/tab-strip.mjs +48 -0
- package/src/lib/tab-strip.mjs.map +1 -0
- package/src/lib/value-words.d.mts +10 -0
- package/src/lib/value-words.d.ts +10 -0
- package/src/lib/value-words.js +38 -0
- package/src/lib/value-words.js.map +1 -0
- package/src/lib/value-words.mjs +35 -0
- package/src/lib/value-words.mjs.map +1 -0
- package/src/lib/viewer.d.mts +54 -5
- package/src/lib/viewer.d.ts +54 -5
- package/src/lib/viewer.js +924 -297
- package/src/lib/viewer.js.map +1 -1
- package/src/lib/viewer.mjs +926 -299
- package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.js
CHANGED
|
@@ -9,6 +9,14 @@ const list_1 = require("./list");
|
|
|
9
9
|
const look_1 = require("./look");
|
|
10
10
|
const place_1 = require("./place");
|
|
11
11
|
const dom_1 = require("./dom");
|
|
12
|
+
const open_1 = require("./open");
|
|
13
|
+
const say_1 = require("./say");
|
|
14
|
+
const tab_strip_1 = require("./tab-strip");
|
|
15
|
+
const value_words_1 = require("./value-words");
|
|
16
|
+
const hotkeys_1 = require("./hotkeys");
|
|
17
|
+
const record_bar_1 = require("./record-bar");
|
|
18
|
+
const attachment_preview_1 = require("./attachment-preview");
|
|
19
|
+
const record_styles_1 = require("./record-styles");
|
|
12
20
|
const labels_1 = require("./labels");
|
|
13
21
|
const focus_trap_1 = require("./focus-trap");
|
|
14
22
|
var labels_2 = require("./labels");
|
|
@@ -23,7 +31,8 @@ let mounts = 0;
|
|
|
23
31
|
function mountViewer(host, options) {
|
|
24
32
|
const doc = host.ownerDocument;
|
|
25
33
|
// The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
|
|
26
|
-
|
|
34
|
+
// A line's own form reads the record the line is on as parent.
|
|
35
|
+
const checked = (0, core_1.checkPage)(options.page, options.parent ? { parent: options.parent.fields } : {});
|
|
27
36
|
if (!checked.ok) {
|
|
28
37
|
throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
|
|
29
38
|
}
|
|
@@ -31,20 +40,60 @@ function mountViewer(host, options) {
|
|
|
31
40
|
const tag = options.locale ?? checked.page.language;
|
|
32
41
|
const localized = tag ? (0, core_1.localizePage)(checked.page, tag) : checked.page;
|
|
33
42
|
const page = options.translate ? (0, core_1.translatePage)(localized, options.translate) : localized;
|
|
43
|
+
/** Another page as this one is shown: in its language, through the app's translator. */
|
|
44
|
+
const shownAs = (other) => {
|
|
45
|
+
const own = tag ? (0, core_1.localizePage)(other, tag) : other;
|
|
46
|
+
return options.translate ? (0, core_1.translatePage)(own, options.translate) : own;
|
|
47
|
+
};
|
|
34
48
|
const locale = (0, labels_1.ownLocale)(tag);
|
|
35
49
|
const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
|
|
36
|
-
const dialogs = (0, related_1.pageDialogs)(options);
|
|
50
|
+
const dialogs = (0, related_1.pageDialogs)(options, (message, tone) => actionHost.say(message, tone));
|
|
37
51
|
const ownsForm = !options.form;
|
|
38
|
-
const form = options.form ?? (0, core_1.createForm)({ ...options, page, messages: options.messages ?? core_1.MESSAGES[locale] });
|
|
39
52
|
const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
|
|
53
|
+
/**
|
|
54
|
+
* What the page's steps ask of the screen: a page opened, words said, a
|
|
55
|
+
* question asked, a tab shown — the viewer's own, under the app's. A form
|
|
56
|
+
* made elsewhere (`form`) keeps the host it was made with.
|
|
57
|
+
*/
|
|
58
|
+
const actionHost = {
|
|
59
|
+
open: (request) => (0, open_1.openPage)({
|
|
60
|
+
options,
|
|
61
|
+
place: host,
|
|
62
|
+
root,
|
|
63
|
+
title: (other) => shownAs(other).title || other.id,
|
|
64
|
+
back: labels.back,
|
|
65
|
+
missing: (id) => fill(labels.pageMissing, { page: id }),
|
|
66
|
+
say: (message, tone) => actionHost.say(message, tone),
|
|
67
|
+
post: (message) => form.post(message),
|
|
68
|
+
name: () => recordName() || labels.newRecord,
|
|
69
|
+
}, request),
|
|
70
|
+
say: (message, tone) => say(message, tone),
|
|
71
|
+
ask: (message) => confirm(message),
|
|
72
|
+
show: (target) => {
|
|
73
|
+
const show = tabs.get(target);
|
|
74
|
+
if (!show?.())
|
|
75
|
+
throw new Error(`No tab "${target}" can be shown here`);
|
|
76
|
+
},
|
|
77
|
+
// A new tab opened apart from this page, or this one sent there.
|
|
78
|
+
openUrl: (url, newTab) => (newTab ? doc.defaultView?.open(url, '_blank', 'noopener,noreferrer') : doc.defaultView?.location.assign(url)),
|
|
79
|
+
...options.host,
|
|
80
|
+
};
|
|
81
|
+
const form = options.form ?? (0, core_1.createForm)({ ...options, page, host: actionHost, messages: options.messages ?? core_1.MESSAGES[locale] });
|
|
40
82
|
const widgetLabels = widgets_1.WIDGET_LABELS[locale];
|
|
41
83
|
const dir = options.dir ?? (tag && (0, core_1.isRightToLeft)(tag) ? 'rtl' : undefined);
|
|
42
84
|
const prefix = `fd${++mounts}`;
|
|
43
85
|
const updaters = [];
|
|
86
|
+
/**
|
|
87
|
+
* Where a part's updater goes as it is drawn: the form's own list, or the list
|
|
88
|
+
* of the tab it is drawn in, which runs only while that tab is shown.
|
|
89
|
+
*/
|
|
90
|
+
let sink = updaters;
|
|
44
91
|
const cleanups = [];
|
|
45
|
-
const
|
|
92
|
+
const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
93
|
+
const uid = ownUid;
|
|
46
94
|
const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
|
|
47
95
|
(0, widgets_1.installStyles)(doc);
|
|
96
|
+
(0, record_styles_1.installRecordStyles)(doc);
|
|
48
97
|
function el(tag, attrs = {}, ...children) {
|
|
49
98
|
const node = doc.createElement(tag);
|
|
50
99
|
for (const [name, value] of Object.entries(attrs))
|
|
@@ -66,287 +115,609 @@ function mountViewer(host, options) {
|
|
|
66
115
|
const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: tag, 'data-max-width': page.maxWidth });
|
|
67
116
|
(0, look_1.applyLook)(root, page.look);
|
|
68
117
|
const confirm = options.confirm ?? dialogConfirm;
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
box?.setAttribute('placeholder', labelText);
|
|
91
|
-
}
|
|
92
|
-
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
|
|
93
|
-
// `for` stays: a custom field that puts the id on its own input is
|
|
94
|
-
// labelled natively. Only a wrapper with a role (a radio group, say) may
|
|
95
|
-
// carry a name; ARIA forbids naming a plain div or span.
|
|
96
|
-
// A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
|
|
97
|
-
const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
|
|
98
|
-
named?.setAttribute('aria-labelledby', label.id);
|
|
99
|
-
label.addEventListener('click', () => widget.focus());
|
|
100
|
-
}
|
|
101
|
-
const helpText = node.help ?? def.help;
|
|
102
|
-
const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
|
|
103
|
-
// Not an alert of its own: a refused save is announced once, naming every field to look at.
|
|
104
|
-
const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
|
|
105
|
-
// A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
|
|
106
|
-
const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
|
|
107
|
-
wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
|
|
108
|
-
if (widget.destroy)
|
|
109
|
-
cleanups.push(() => widget.destroy?.());
|
|
110
|
-
// An answer rule's warning waits until the person leaves the field: no advice
|
|
111
|
-
// mid-word. One already shown stays while they put it right, and goes once they have.
|
|
112
|
-
let typing = false;
|
|
113
|
-
let advice = '';
|
|
114
|
-
wrapper.addEventListener('focusin', () => (typing = true));
|
|
115
|
-
wrapper.addEventListener('focusout', (event) => {
|
|
116
|
-
if (wrapper.contains(event.relatedTarget))
|
|
117
|
-
return;
|
|
118
|
-
typing = false;
|
|
119
|
-
// Not at once: focus also leaves when a redraw removes the focused part
|
|
120
|
-
// (a deleted line's button), and that redraw must finish first.
|
|
121
|
-
queueMicrotask(() => update(form.getState()));
|
|
122
|
-
});
|
|
123
|
-
const update = (state) => {
|
|
124
|
-
const shown = form.node(node.id);
|
|
125
|
-
(0, dom_1.setHidden)(wrapper, shown.invisible);
|
|
126
|
-
wrapper.classList.toggle('fd-required', shown.required);
|
|
127
|
-
const message = state.errors[node.field];
|
|
128
|
-
(0, dom_1.setHidden)(error, !message);
|
|
129
|
-
(0, dom_1.setText)(error, message ?? '');
|
|
130
|
-
const now = state.warnings[node.field] ?? '';
|
|
131
|
-
if (!typing || !now)
|
|
132
|
-
advice = now;
|
|
133
|
-
// An error shown says enough: the rule's warning gives way to it.
|
|
134
|
-
const warned = [message ? '' : advice, state.warning && state.warningField === node.field ? state.warning : ''].filter(Boolean).join(' ');
|
|
135
|
-
(0, dom_1.setHidden)(warning, !warned);
|
|
136
|
-
(0, dom_1.setText)(warning, warned);
|
|
137
|
-
if (mark) {
|
|
138
|
-
const value = state.values[node.field];
|
|
139
|
-
const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
|
|
140
|
-
const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
|
|
141
|
-
wrapper.classList.toggle('fd-valid', valid);
|
|
142
|
-
mark.toggleAttribute('hidden', !valid);
|
|
118
|
+
const say = (0, say_1.sayer)(root, el, labels.dismiss);
|
|
119
|
+
/** The tabs a step can show, by id: each shows its tab, false when it is hidden. */
|
|
120
|
+
const tabs = new Map();
|
|
121
|
+
/** For each field in a tab, how to show its tab: a problem there is shown before the focus goes to it. */
|
|
122
|
+
const problemTabs = new Map();
|
|
123
|
+
/**
|
|
124
|
+
* A press runs its steps one run at a time: the button busy meanwhile, and
|
|
125
|
+
* not pressed again. A check or a save that stops it takes the focus to the
|
|
126
|
+
* first problem, and says which fields, as Save does.
|
|
127
|
+
*/
|
|
128
|
+
async function press(button, run) {
|
|
129
|
+
if (button.hasAttribute('aria-busy'))
|
|
130
|
+
return;
|
|
131
|
+
button.setAttribute('aria-busy', 'true');
|
|
132
|
+
button.setAttribute('aria-disabled', 'true');
|
|
133
|
+
try {
|
|
134
|
+
const { reason } = await run();
|
|
135
|
+
if (reason === 'check' || reason === 'save') {
|
|
136
|
+
if (!form.getState().saveProblem)
|
|
137
|
+
announce(checkText(form.getState()));
|
|
138
|
+
focusFirstProblem();
|
|
143
139
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
invalid: !!message,
|
|
150
|
-
describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
151
|
-
});
|
|
152
|
-
};
|
|
153
|
-
updaters.push(update);
|
|
154
|
-
return wrapper;
|
|
155
|
-
}
|
|
156
|
-
function hideWhen(element, id) {
|
|
157
|
-
updaters.push(() => (0, dom_1.setHidden)(element, form.node(id).invisible));
|
|
158
|
-
}
|
|
159
|
-
function buttonItem(node) {
|
|
160
|
-
const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
|
|
161
|
-
spans(button, node.colspan);
|
|
162
|
-
button.addEventListener('click', async () => {
|
|
163
|
-
if (node.confirm && !(await confirm(node.confirm)))
|
|
164
|
-
return;
|
|
165
|
-
await form.runAction(node.id);
|
|
166
|
-
});
|
|
167
|
-
hideWhen(button, node.id);
|
|
168
|
-
return button;
|
|
169
|
-
}
|
|
170
|
-
function textItem(node) {
|
|
171
|
-
const style = node.style ?? 'paragraph';
|
|
172
|
-
const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
|
|
173
|
-
spans(element, node.colspan);
|
|
174
|
-
hideWhen(element, node.id);
|
|
175
|
-
return element;
|
|
176
|
-
}
|
|
177
|
-
function slotItem(node) {
|
|
178
|
-
const element = el('div', { class: 'fd-slot', 'data-slot': node.name, 'data-node': node.id });
|
|
179
|
-
const cleanup = options.slots?.[node.name]?.(element, { form, name: node.name });
|
|
180
|
-
if (cleanup)
|
|
181
|
-
cleanups.push(cleanup);
|
|
182
|
-
hideWhen(element, node.id);
|
|
183
|
-
return element;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
button.removeAttribute('aria-busy');
|
|
143
|
+
button.removeAttribute('aria-disabled');
|
|
144
|
+
}
|
|
184
145
|
}
|
|
146
|
+
// ---- the parts -------------------------------------------------------
|
|
147
|
+
/** The fields that take the focus as the record opens, in the order they are drawn. */
|
|
148
|
+
const focusers = [];
|
|
149
|
+
/** Folded sections, and how to open each: a problem inside one has to be seen. */
|
|
150
|
+
const folds = new Map();
|
|
151
|
+
/** Every part drawn again from the state of the page shown. */
|
|
152
|
+
const renderNow = () => render(form.getState());
|
|
153
|
+
/** Set once the viewer is on the page; set again when it is taken away. */
|
|
154
|
+
let mounted = false;
|
|
155
|
+
let gone = false;
|
|
185
156
|
/** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
|
|
186
157
|
function spans(element, colspan) {
|
|
187
158
|
if (colspan)
|
|
188
159
|
element.style.setProperty('--fd-span', String(colspan));
|
|
189
160
|
}
|
|
161
|
+
/** Where the parts of a page, a tab or a step sit: one column, the page's labels. */
|
|
162
|
+
const top = (onPage) => ({ columns: 1, onPage, labels: page.look?.labels });
|
|
190
163
|
/**
|
|
191
|
-
*
|
|
192
|
-
*
|
|
164
|
+
* The parts of one page, drawn with its own form: the page shown, or a saved
|
|
165
|
+
* form placed in it. Inside, `form` and `page` are the scope's.
|
|
193
166
|
*/
|
|
194
|
-
function
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
167
|
+
function drawer(scope) {
|
|
168
|
+
const { form, page } = scope;
|
|
169
|
+
const uid = (id) => ownUid(scope.prefix + id);
|
|
170
|
+
/** A part's updater, handed the state of the part's own form. */
|
|
171
|
+
const watch = (update) => sink.push(scope.root ? update : () => update(form.getState()));
|
|
172
|
+
/** A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none. */
|
|
173
|
+
function fieldItem(node, place) {
|
|
174
|
+
const def = page.fields[node.field];
|
|
175
|
+
const id = uid(node.id);
|
|
176
|
+
const labelsAt = (0, place_1.labelPlace)(node, def.type, place);
|
|
177
|
+
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, 'data-focus': node.focus && scope.root ? '' : undefined });
|
|
178
|
+
if (node.colspan)
|
|
179
|
+
wrapper.style.setProperty('--fd-span', String(node.colspan));
|
|
180
|
+
const labelText = node.label ?? def.label;
|
|
181
|
+
const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
|
|
182
|
+
// The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
|
|
183
|
+
const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
|
|
184
|
+
if (mark) {
|
|
185
|
+
mark.classList.add('fd-valid-mark');
|
|
186
|
+
label.append(mark);
|
|
187
|
+
}
|
|
188
|
+
const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
189
|
+
// The field that takes the focus as the record opens (Flectra's default_focus): the page's own, not a saved form's inside it.
|
|
190
|
+
if (node.focus && scope.root)
|
|
191
|
+
focusers.push({ wrapper, focus: () => widget.focus() });
|
|
192
|
+
// 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.
|
|
193
|
+
const textBox = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
|
|
194
|
+
if (labelsAt === 'hidden' && !node.placeholder)
|
|
195
|
+
textBox?.setAttribute('placeholder', labelText);
|
|
196
|
+
// Words chosen by a condition (placeholderWhen), else what the box showed as it was drawn.
|
|
197
|
+
const drawnPlaceholder = textBox?.getAttribute('placeholder') ?? null;
|
|
198
|
+
const chosenPlaceholder = node.placeholderWhen?.length ? textBox : null;
|
|
199
|
+
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
|
|
200
|
+
// `for` stays: a custom field that puts the id on its own input is
|
|
201
|
+
// labelled natively. Only a wrapper with a role (a radio group, say) may
|
|
202
|
+
// carry a name; ARIA forbids naming a plain div or span.
|
|
203
|
+
// A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
|
|
204
|
+
const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
|
|
205
|
+
named?.setAttribute('aria-labelledby', label.id);
|
|
206
|
+
label.addEventListener('click', () => widget.focus());
|
|
207
|
+
}
|
|
208
|
+
const helpText = node.help ?? def.help;
|
|
209
|
+
// Under the field, behind a (?) by its label, or both: the field's own way, else the page's.
|
|
210
|
+
const helpWay = node.helpShown ?? page.look?.helpShown ?? 'below';
|
|
211
|
+
const help = helpText && helpWay !== 'tooltip' ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
|
|
212
|
+
const tip = helpText && helpWay !== 'below' ? helpTip(`${id}-tip`, helpText, fill(labels.helpFor, { label: labelText })) : null;
|
|
213
|
+
// In the label, as Flectra's: a press on it does not move into the box. A label out of sight keeps it after the box.
|
|
214
|
+
if (tip && labelsAt === 'hidden')
|
|
215
|
+
widget.element.after(tip.element);
|
|
216
|
+
else if (tip)
|
|
217
|
+
label.append(tip.element);
|
|
218
|
+
// Not an alert of its own: a refused save is announced once, naming every field to look at.
|
|
219
|
+
const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
|
|
220
|
+
// A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
|
|
221
|
+
const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
|
|
222
|
+
wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
|
|
223
|
+
// Read-only, as words in place of the box, when the page draws them so.
|
|
224
|
+
const asText = page.look?.readonlyShown === 'text' && (0, widgets_1.readsAsText)(def, node) ? (0, widgets_1.readText)({ document: doc, field: def, node, id, locale, labels: widgetLabels, dialogs }) : null;
|
|
225
|
+
if (asText)
|
|
226
|
+
widget.element.after(asText.element);
|
|
227
|
+
if (widget.destroy)
|
|
228
|
+
cleanups.push(() => widget.destroy?.());
|
|
229
|
+
// An answer rule's warning waits until the person leaves the field: no advice
|
|
230
|
+
// mid-word. One already shown stays while they put it right, and goes once they have.
|
|
231
|
+
let typing = false;
|
|
232
|
+
let advice = '';
|
|
233
|
+
wrapper.addEventListener('focusin', () => (typing = true));
|
|
234
|
+
wrapper.addEventListener('focusout', (event) => {
|
|
235
|
+
if (wrapper.contains(event.relatedTarget))
|
|
236
|
+
return;
|
|
237
|
+
typing = false;
|
|
238
|
+
// Not at once: focus also leaves when a redraw removes the focused part
|
|
239
|
+
// (a deleted line's button), and that redraw must finish first.
|
|
240
|
+
queueMicrotask(() => update(form.getState()));
|
|
241
|
+
});
|
|
242
|
+
const toned = node.tones !== undefined || node.bold !== undefined;
|
|
243
|
+
const update = (state) => {
|
|
244
|
+
const shown = form.node(node.id);
|
|
245
|
+
(0, dom_1.setHidden)(wrapper, shown.invisible);
|
|
246
|
+
wrapper.classList.toggle('fd-required', shown.required);
|
|
247
|
+
// Its value's tone and bold while a condition holds: Flectra's decoration-* on a field.
|
|
248
|
+
if (toned) {
|
|
249
|
+
const look = form.fieldTone(node.id);
|
|
250
|
+
(0, dom_1.setAttr)(wrapper, 'data-tone', look.tone);
|
|
251
|
+
wrapper.classList.toggle('fd-value-bold', look.bold);
|
|
252
|
+
}
|
|
253
|
+
if (chosenPlaceholder)
|
|
254
|
+
(0, dom_1.setAttr)(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? drawnPlaceholder);
|
|
255
|
+
const message = state.errors[node.field];
|
|
256
|
+
(0, dom_1.setHidden)(error, !message);
|
|
257
|
+
(0, dom_1.setText)(error, message ?? '');
|
|
258
|
+
const now = state.warnings[node.field] ?? '';
|
|
259
|
+
if (!typing || !now)
|
|
260
|
+
advice = now;
|
|
261
|
+
// An error shown says enough: the rule's warning gives way to it.
|
|
262
|
+
const warned = [message ? '' : advice, state.warning && state.warningField === node.field ? state.warning : ''].filter(Boolean).join(' ');
|
|
263
|
+
(0, dom_1.setHidden)(warning, !warned);
|
|
264
|
+
(0, dom_1.setText)(warning, warned);
|
|
265
|
+
if (mark) {
|
|
266
|
+
const value = state.values[node.field];
|
|
267
|
+
const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
|
|
268
|
+
const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
|
|
269
|
+
wrapper.classList.toggle('fd-valid', valid);
|
|
270
|
+
mark.toggleAttribute('hidden', !valid);
|
|
271
|
+
}
|
|
272
|
+
const readonly = shown.readonly || locked || scope.locked();
|
|
273
|
+
if (asText) {
|
|
274
|
+
(0, dom_1.setHidden)(widget.element, readonly);
|
|
275
|
+
(0, dom_1.setHidden)(asText.element, !readonly);
|
|
276
|
+
(0, dom_1.setAttr)(wrapper, 'data-read-text', readonly ? '' : null);
|
|
277
|
+
if (readonly)
|
|
278
|
+
asText.update(state.values[node.field], state.values);
|
|
279
|
+
}
|
|
280
|
+
widget.update({
|
|
281
|
+
value: state.values[node.field],
|
|
282
|
+
values: state.values,
|
|
283
|
+
readonly,
|
|
284
|
+
required: shown.required,
|
|
285
|
+
invalid: !!message,
|
|
286
|
+
describedBy: [help?.id ?? tip?.bubble.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
287
|
+
});
|
|
288
|
+
};
|
|
289
|
+
watch(update);
|
|
290
|
+
return wrapper;
|
|
291
|
+
}
|
|
292
|
+
/** A (?) that shows a field's help in a bubble: on hover, on focus, or kept open by a press — a tap — and gone with Escape. */
|
|
293
|
+
function helpTip(bubbleId, text, name) {
|
|
294
|
+
const bubble = el('span', { class: 'fd-help-bubble', role: 'tooltip', id: bubbleId, hidden: '' }, text);
|
|
295
|
+
const button = el('button', { type: 'button', class: 'fd-help-tip', 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, '?');
|
|
296
|
+
let kept = false;
|
|
297
|
+
const show = (open) => {
|
|
298
|
+
bubble.hidden = !open;
|
|
299
|
+
button.setAttribute('aria-expanded', String(open));
|
|
300
|
+
};
|
|
301
|
+
button.addEventListener('mouseenter', () => show(true));
|
|
302
|
+
button.addEventListener('mouseleave', () => show(kept));
|
|
303
|
+
button.addEventListener('focus', () => show(true));
|
|
304
|
+
button.addEventListener('blur', () => ((kept = false), show(false)));
|
|
305
|
+
button.addEventListener('click', (event) => {
|
|
306
|
+
event.preventDefault();
|
|
307
|
+
kept = !kept;
|
|
308
|
+
show(kept);
|
|
309
|
+
});
|
|
310
|
+
button.addEventListener('keydown', (event) => {
|
|
311
|
+
if (event.key !== 'Escape' || bubble.hidden)
|
|
312
|
+
return;
|
|
313
|
+
// Escape closes the bubble, not the dialog the form is in.
|
|
314
|
+
event.stopPropagation();
|
|
315
|
+
kept = false;
|
|
316
|
+
show(false);
|
|
317
|
+
});
|
|
318
|
+
return { element: el('span', { class: 'fd-help-tip-wrap' }, button, bubble), bubble };
|
|
319
|
+
}
|
|
320
|
+
function hideWhen(element, id) {
|
|
321
|
+
watch(() => (0, dom_1.setHidden)(element, form.node(id).invisible));
|
|
322
|
+
}
|
|
323
|
+
function buttonItem(node) {
|
|
324
|
+
const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
|
|
325
|
+
if (node.hotkey)
|
|
326
|
+
(0, hotkeys_1.hotkeyOn)(button, node.hotkey, node.label);
|
|
327
|
+
spans(button, node.colspan);
|
|
328
|
+
// Its confirmation, then its steps: the form asks, through the viewer.
|
|
329
|
+
button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
|
|
330
|
+
hideWhen(button, node.id);
|
|
331
|
+
// A header's button is drawn here too, never through item(): hidden at its widths the same way.
|
|
332
|
+
return hiddenOn(node, button);
|
|
333
|
+
}
|
|
334
|
+
function textItem(node, inline = false) {
|
|
335
|
+
const style = node.style ?? 'paragraph';
|
|
336
|
+
// Its words, with the values of the fields they name.
|
|
337
|
+
const words = (0, value_words_1.valueWords)(doc, page.fields, node.text, locale);
|
|
338
|
+
const element = inline
|
|
339
|
+
? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...words.nodes)
|
|
340
|
+
: style === 'alert'
|
|
341
|
+
? el('div', { class: `fd-alert fd-text-alert fd-tone-${node.tone ?? 'info'}`, role: 'status', 'data-node': node.id }, el('span', { class: 'fd-alert-message' }, ...words.nodes))
|
|
342
|
+
: el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...words.nodes);
|
|
343
|
+
spans(element, node.colspan);
|
|
344
|
+
hideWhen(element, node.id);
|
|
345
|
+
watch((state) => words.update(state.values));
|
|
346
|
+
return element;
|
|
347
|
+
}
|
|
348
|
+
function slotItem(node) {
|
|
349
|
+
const element = el('div', { class: 'fd-slot', 'data-slot': node.name, 'data-node': node.id });
|
|
350
|
+
const cleanup = options.slots?.[node.name]?.(element, { form, name: node.name });
|
|
351
|
+
if (cleanup)
|
|
352
|
+
cleanups.push(cleanup);
|
|
353
|
+
hideWhen(element, node.id);
|
|
354
|
+
return element;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* A grid of parts. With `columns` null, it has none of its own: it lays its
|
|
358
|
+
* parts on the columns of the grid round it (an arrangement on its tracks).
|
|
359
|
+
*/
|
|
360
|
+
function grid(children, columns, place) {
|
|
361
|
+
const box = el('div', { class: 'fd-grid' });
|
|
362
|
+
if (columns !== null) {
|
|
363
|
+
box.style.setProperty('--fd-columns', String((0, core_1.wideColumns)(columns)));
|
|
364
|
+
// Twelfths: eleven gaps between them, which shrink in a narrow column rather than push past it.
|
|
365
|
+
if ((0, core_1.wideColumns)(columns) === 12)
|
|
366
|
+
box.setAttribute('data-twelfths', '');
|
|
367
|
+
// Counts given for the narrower widths replace the skin's own stacking there.
|
|
368
|
+
if (typeof columns === 'object') {
|
|
369
|
+
for (const width of ['medium', 'narrow']) {
|
|
370
|
+
const count = columns[width];
|
|
371
|
+
if (count === undefined)
|
|
372
|
+
continue;
|
|
373
|
+
box.setAttribute(`data-columns-${width}`, String(count));
|
|
374
|
+
box.style.setProperty(`--fd-columns-${width}`, String(count));
|
|
375
|
+
}
|
|
206
376
|
}
|
|
207
377
|
}
|
|
378
|
+
box.append(...children.map((child) => item(child, place)));
|
|
379
|
+
return box;
|
|
208
380
|
}
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
381
|
+
/**
|
|
382
|
+
* A row of twelfths led by a field whose label sits beside it, as Flectra's
|
|
383
|
+
* `<label/><div class="o_row">`: that label goes with the labels round it,
|
|
384
|
+
* and the parts share the room their values have.
|
|
385
|
+
*/
|
|
386
|
+
function labelledRow(node, plan) {
|
|
387
|
+
const [first] = node.children;
|
|
388
|
+
if (!plan.arrangement || plan.at === 'tracks' || (0, core_1.wideColumns)(node.columns) !== 12 || node.children.length < 2 || first?.type !== 'field')
|
|
389
|
+
return null;
|
|
390
|
+
// One line of them: parts running past twelve twelfths are a column of fields, each with its own label.
|
|
391
|
+
if (node.children.reduce((sum, child) => sum + (child.colspan ?? 1), 0) > 12)
|
|
392
|
+
return null;
|
|
393
|
+
const type = page.fields[first.field]?.type;
|
|
394
|
+
const where = type ? (0, place_1.labelPlace)(first, type, plan.inner.labels) : 'hidden';
|
|
395
|
+
// The skin's place: beside in the underline skin, above in the outlined one, as the stylesheet says.
|
|
396
|
+
return where === undefined ? 'skin' : where === 'beside' ? 'beside' : null;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* A line of parts, as Flectra's `<label/><div class="o_row">`: its title as
|
|
400
|
+
* the line's label, where the labels round it sit, and its parts after it,
|
|
401
|
+
* each as wide as it needs, its fields named for a screen reader.
|
|
402
|
+
*/
|
|
403
|
+
function inlineItem(node, place) {
|
|
404
|
+
const line = el('div', { class: 'fd-field fd-oneline', 'data-node': node.id, 'data-style': 'inline', 'data-labels': place.labels });
|
|
405
|
+
spans(line, node.colspan);
|
|
406
|
+
const row = el('div', { class: 'fd-oneline-row', role: 'group' }, ...node.children.map((child) => item(child, { columns: 1, onPage: false, inline: true })));
|
|
407
|
+
if (node.title) {
|
|
408
|
+
const title = el('span', { class: 'fd-label', id: uid(`${node.id}-label`) }, node.title);
|
|
409
|
+
row.setAttribute('aria-labelledby', title.id);
|
|
410
|
+
line.append(title);
|
|
411
|
+
}
|
|
412
|
+
line.append(row);
|
|
413
|
+
hideWhen(line, node.id);
|
|
414
|
+
return line;
|
|
415
|
+
}
|
|
416
|
+
function sectionItem(node, place) {
|
|
417
|
+
if (node.style === 'inline')
|
|
418
|
+
return inlineItem(node, place);
|
|
419
|
+
const plan = (0, place_1.planSection)(node, place);
|
|
420
|
+
// An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
|
|
421
|
+
const section = el(plan.arrangement ? 'div' : 'fieldset', {
|
|
422
|
+
class: 'fd-section',
|
|
423
|
+
'data-node': node.id,
|
|
424
|
+
'data-style': plan.style,
|
|
425
|
+
'data-place': plan.at,
|
|
426
|
+
'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
|
|
427
|
+
});
|
|
428
|
+
spans(section, node.colspan);
|
|
429
|
+
const row = labelledRow(node, plan);
|
|
430
|
+
if (row)
|
|
431
|
+
section.setAttribute('data-row', row);
|
|
432
|
+
if (node.labelWidth)
|
|
433
|
+
section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
|
|
434
|
+
const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
|
|
435
|
+
const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
|
|
436
|
+
if (node.title && node.collapsible) {
|
|
437
|
+
content.id = uid(`${node.id}-content`);
|
|
438
|
+
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));
|
|
439
|
+
let open = node.collapsed !== true;
|
|
440
|
+
const show = (next) => {
|
|
441
|
+
open = next;
|
|
442
|
+
toggle.setAttribute('aria-expanded', String(open));
|
|
443
|
+
content.hidden = !open;
|
|
444
|
+
if (description)
|
|
445
|
+
description.hidden = !open;
|
|
446
|
+
section.classList.toggle('fd-section-folded', !open);
|
|
447
|
+
};
|
|
448
|
+
toggle.addEventListener('click', () => show(!open));
|
|
449
|
+
folds.set(section, () => show(true));
|
|
450
|
+
show(open);
|
|
451
|
+
section.append(el('legend', { class: 'fd-section-title' }, toggle));
|
|
452
|
+
}
|
|
453
|
+
else if (node.title) {
|
|
454
|
+
section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
|
|
455
|
+
}
|
|
456
|
+
if (description)
|
|
457
|
+
section.append(description);
|
|
458
|
+
section.append(content);
|
|
459
|
+
hideWhen(section, node.id);
|
|
460
|
+
return section;
|
|
461
|
+
}
|
|
462
|
+
function tabsItem(node, place) {
|
|
463
|
+
const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
|
|
464
|
+
spans(box, node.colspan);
|
|
465
|
+
const list = el('div', { class: 'fd-tablist', role: 'tablist' });
|
|
466
|
+
// The tab someone picked. Until they pick, the first visible tab is open —
|
|
467
|
+
// a tab hidden while the record loads must not leave its neighbour open.
|
|
468
|
+
let picked = null;
|
|
469
|
+
let active = node.children[0].id;
|
|
470
|
+
/** A tab shown, by a person or a step: the page's steps for it run. */
|
|
471
|
+
const show = (tab) => {
|
|
472
|
+
picked = active = tab;
|
|
473
|
+
renderNow();
|
|
474
|
+
form.shown(tab);
|
|
242
475
|
};
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
476
|
+
const parts = node.children.map((tab) => {
|
|
477
|
+
const tabId = uid(`${tab.id}-tab`);
|
|
478
|
+
const panelId = uid(`${tab.id}-panel`);
|
|
479
|
+
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));
|
|
480
|
+
const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId });
|
|
481
|
+
/**
|
|
482
|
+
* A tab's parts are drawn when it is first shown, and kept up to date
|
|
483
|
+
* only while it is shown: a page of many tabs costs one tab's work.
|
|
484
|
+
*/
|
|
485
|
+
const own = [];
|
|
486
|
+
let built = false;
|
|
487
|
+
const build = () => {
|
|
488
|
+
if (built)
|
|
489
|
+
return;
|
|
490
|
+
built = true;
|
|
491
|
+
const outer = sink;
|
|
492
|
+
sink = own;
|
|
493
|
+
try {
|
|
494
|
+
// A tab's parts sit where the tabs do: on the page, or in the box round them.
|
|
495
|
+
panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
|
|
496
|
+
}
|
|
497
|
+
finally {
|
|
498
|
+
sink = outer;
|
|
499
|
+
}
|
|
500
|
+
};
|
|
501
|
+
const open = () => !form.node(tab.id).invisible && (show(tab.id), true);
|
|
502
|
+
button.addEventListener('click', () => show(tab.id));
|
|
503
|
+
if (!tabs.has(tab.id))
|
|
504
|
+
tabs.set(tab.id, open);
|
|
505
|
+
// The fields it holds, to mark it while one has a problem and to open it at one.
|
|
506
|
+
const fields = fieldsIn(tab.children);
|
|
507
|
+
if (scope.root)
|
|
508
|
+
for (const name of fields)
|
|
509
|
+
if (!problemTabs.has(name))
|
|
510
|
+
problemTabs.set(name, open);
|
|
511
|
+
list.append(button);
|
|
512
|
+
return { tab, button, panel, own, build, fields };
|
|
274
513
|
});
|
|
275
|
-
list.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
514
|
+
list.addEventListener('keydown', (event) => {
|
|
515
|
+
const shown = parts.filter((p) => !p.button.hidden);
|
|
516
|
+
const at = shown.findIndex((p) => p.tab.id === active);
|
|
517
|
+
const move = { ArrowRight: 1, ArrowLeft: -1, Home: -at, End: shown.length - 1 - at }[event.key];
|
|
518
|
+
if (move === undefined || at === -1)
|
|
519
|
+
return;
|
|
520
|
+
event.preventDefault();
|
|
521
|
+
const rtl = root.getAttribute('dir') === 'rtl' && (event.key === 'ArrowRight' || event.key === 'ArrowLeft');
|
|
522
|
+
const next = shown[(at + (rtl ? -move : move) + shown.length) % shown.length];
|
|
523
|
+
show(next.tab.id);
|
|
524
|
+
next.button.focus();
|
|
525
|
+
});
|
|
526
|
+
// Too long for its room, the strip scrolls; the tab chosen, however, comes into sight in it.
|
|
527
|
+
const strip = (0, tab_strip_1.tabStrip)(list, el);
|
|
528
|
+
cleanups.push(strip.destroy);
|
|
529
|
+
box.append(strip.element, ...parts.map((p) => p.panel));
|
|
530
|
+
let shownKey = '';
|
|
531
|
+
let revealed = '';
|
|
532
|
+
watch((state) => {
|
|
533
|
+
const tabsHidden = form.node(node.id).invisible;
|
|
534
|
+
(0, dom_1.setHidden)(box, tabsHidden);
|
|
535
|
+
const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
|
|
536
|
+
const keep = picked !== null && shown.some((p) => p.tab.id === picked);
|
|
537
|
+
if (keep)
|
|
538
|
+
active = picked;
|
|
539
|
+
else if (shown.length)
|
|
540
|
+
active = shown[0].tab.id;
|
|
541
|
+
const errors = form.getState().errors;
|
|
542
|
+
for (const p of parts) {
|
|
543
|
+
const selected = p.tab.id === active;
|
|
544
|
+
(0, dom_1.setHidden)(p.button, !shown.includes(p));
|
|
545
|
+
(0, dom_1.setAttr)(p.button, 'aria-selected', String(selected));
|
|
546
|
+
if (p.button.tabIndex !== (selected ? 0 : -1))
|
|
547
|
+
p.button.tabIndex = selected ? 0 : -1;
|
|
548
|
+
(0, dom_1.setAttr)(p.button, 'data-problem', p.fields.some((name) => errors[name]) ? '' : null);
|
|
549
|
+
(0, dom_1.setHidden)(p.panel, !selected);
|
|
550
|
+
// The tab shown: drawn if it is new, and brought up to date — it was left alone while out of sight.
|
|
551
|
+
if (selected && !tabsHidden) {
|
|
552
|
+
p.build();
|
|
553
|
+
for (const update of p.own)
|
|
554
|
+
update(state);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
// Measured only when it can have changed: the strip's tabs, or the one chosen.
|
|
558
|
+
const key = shown.map((p) => p.tab.id).join(' ');
|
|
559
|
+
if (key !== shownKey) {
|
|
560
|
+
shownKey = key;
|
|
561
|
+
strip.measure();
|
|
562
|
+
}
|
|
563
|
+
if (active !== revealed && list.clientWidth) {
|
|
564
|
+
revealed = active;
|
|
565
|
+
const chosen = parts.find((p) => p.tab.id === active);
|
|
566
|
+
if (chosen)
|
|
567
|
+
strip.reveal(chosen.button);
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
return box;
|
|
571
|
+
}
|
|
572
|
+
/** The fields a tab holds, in its sections and tabs inside it; a saved form placed in it keeps its own. */
|
|
573
|
+
function fieldsIn(children) {
|
|
574
|
+
return children.flatMap((child) => child.type === 'field' ? [child.field]
|
|
575
|
+
: child.type === 'section' ? fieldsIn(child.children)
|
|
576
|
+
: child.type === 'tabs' ? child.children.flatMap((tab) => fieldsIn(tab.children))
|
|
577
|
+
: []);
|
|
578
|
+
}
|
|
579
|
+
function item(node, place) {
|
|
580
|
+
return hiddenOn(node, partItem(node, place));
|
|
581
|
+
}
|
|
582
|
+
/** A part hidden at some widths of the form (`hideOn`), as a phone's: the stylesheet hides it there. */
|
|
583
|
+
function hiddenOn(node, element) {
|
|
584
|
+
if (node.hideOn?.length)
|
|
585
|
+
element.dataset['hideOn'] = node.hideOn.join(' ');
|
|
586
|
+
return element;
|
|
587
|
+
}
|
|
588
|
+
function partItem(node, place) {
|
|
589
|
+
switch (node.type) {
|
|
590
|
+
case 'field':
|
|
591
|
+
return fieldItem(node, place.labels);
|
|
592
|
+
case 'button':
|
|
593
|
+
return buttonItem(node);
|
|
594
|
+
case 'text':
|
|
595
|
+
return textItem(node, place.inline);
|
|
596
|
+
case 'slot':
|
|
597
|
+
return slotItem(node);
|
|
598
|
+
case 'section':
|
|
599
|
+
return sectionItem(node, place);
|
|
600
|
+
case 'tabs':
|
|
601
|
+
return tabsItem(node, place);
|
|
602
|
+
case 'divider':
|
|
603
|
+
case 'spacer':
|
|
604
|
+
case 'image':
|
|
605
|
+
return blockItem(node);
|
|
606
|
+
case 'form':
|
|
607
|
+
return formItem(node, place);
|
|
306
608
|
}
|
|
307
|
-
});
|
|
308
|
-
return box;
|
|
309
|
-
}
|
|
310
|
-
function item(node, place) {
|
|
311
|
-
switch (node.type) {
|
|
312
|
-
case 'field':
|
|
313
|
-
return fieldItem(node, place.labels);
|
|
314
|
-
case 'button':
|
|
315
|
-
return buttonItem(node);
|
|
316
|
-
case 'text':
|
|
317
|
-
return textItem(node);
|
|
318
|
-
case 'slot':
|
|
319
|
-
return slotItem(node);
|
|
320
|
-
case 'section':
|
|
321
|
-
return sectionItem(node, place);
|
|
322
|
-
case 'tabs':
|
|
323
|
-
return tabsItem(node, place);
|
|
324
|
-
case 'divider':
|
|
325
|
-
case 'spacer':
|
|
326
|
-
case 'image':
|
|
327
|
-
return blockItem(node);
|
|
328
609
|
}
|
|
610
|
+
/** A line across the row, empty room, or a picture: shown and hidden by its rule like any part. */
|
|
611
|
+
function blockItem(node) {
|
|
612
|
+
const element = node.type === 'divider' ? el('hr', { class: 'fd-divider', 'data-node': node.id })
|
|
613
|
+
: node.type === 'spacer' ? el('div', { class: 'fd-block fd-spacer', 'aria-hidden': 'true', 'data-node': node.id })
|
|
614
|
+
: figure(node);
|
|
615
|
+
if (node.type !== 'divider' && node.colspan)
|
|
616
|
+
element.style.setProperty('--fd-span', String(node.colspan));
|
|
617
|
+
hideWhen(element, node.id);
|
|
618
|
+
return element;
|
|
619
|
+
}
|
|
620
|
+
/** 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. */
|
|
621
|
+
function figure(node) {
|
|
622
|
+
const image = el('img', { class: 'fd-image', src: node.src, alt: node.alt, loading: 'lazy' });
|
|
623
|
+
const href = node.href && /^(https?:\/\/|mailto:)/i.test(node.href) ? node.href : undefined;
|
|
624
|
+
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)] : []));
|
|
625
|
+
const width = typeof node.width === 'number' ? `${node.width}px` : node.width && { small: '160px', medium: '320px', large: '480px', full: '100%' }[node.width];
|
|
626
|
+
if (width)
|
|
627
|
+
element.style.setProperty('--fd-image-width', width);
|
|
628
|
+
return element;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* A saved form placed here, drawn from the page the app gives for its id
|
|
632
|
+
* with a form of its own inside this one. A quiet placeholder while the
|
|
633
|
+
* page comes; words in its place when it cannot be found, would hold
|
|
634
|
+
* itself, or cannot sit in a form.
|
|
635
|
+
*/
|
|
636
|
+
function formItem(node, place) {
|
|
637
|
+
const box = el('fieldset', {
|
|
638
|
+
class: 'fd-section fd-form-part',
|
|
639
|
+
'data-node': node.id,
|
|
640
|
+
'data-style': 'card',
|
|
641
|
+
'data-on-page': place.onPage ? '' : undefined,
|
|
642
|
+
'aria-busy': 'true',
|
|
643
|
+
});
|
|
644
|
+
spans(box, node.colspan);
|
|
645
|
+
const note = el('p', { class: 'fd-form-part-note' }, labels.loading);
|
|
646
|
+
box.append(note);
|
|
647
|
+
hideWhen(box, node.id);
|
|
648
|
+
const refuse = (words) => {
|
|
649
|
+
box.removeAttribute('aria-busy');
|
|
650
|
+
note.textContent = words;
|
|
651
|
+
note.classList.add('fd-form-part-problem');
|
|
652
|
+
};
|
|
653
|
+
const draw = (found) => {
|
|
654
|
+
if (gone)
|
|
655
|
+
return;
|
|
656
|
+
if (!found)
|
|
657
|
+
return refuse(fill(labels.formMissing, { page: node.page }));
|
|
658
|
+
const saved = shownAs(found);
|
|
659
|
+
const name = saved.title || found.id;
|
|
660
|
+
const at = scope.chain.findIndex((p) => p.id === found.id);
|
|
661
|
+
if (at !== -1)
|
|
662
|
+
return refuse(fill(labels.formInItself, { page: name, chain: [...scope.chain.slice(at).map((p) => p.title), name].join(' → ') }));
|
|
663
|
+
if (found.layout.type !== 'sections' && found.layout.type !== 'tabs')
|
|
664
|
+
return refuse(fill(labels.formNotPlaceable, { page: name }));
|
|
665
|
+
const checked = (0, core_1.checkPage)(saved);
|
|
666
|
+
let inner;
|
|
667
|
+
try {
|
|
668
|
+
if (!checked.ok)
|
|
669
|
+
throw new Error(checked.issues.map((i) => `${i.path}: ${i.message}`).join('; '));
|
|
670
|
+
inner = form.embed(node.id, saved);
|
|
671
|
+
}
|
|
672
|
+
catch (error) {
|
|
673
|
+
console.warn(`Fieldia: the saved form "${found.id}" cannot be shown: ${error.message}`);
|
|
674
|
+
return refuse(fill(labels.formBroken, { page: name }));
|
|
675
|
+
}
|
|
676
|
+
const title = node.title ?? saved.title ?? '';
|
|
677
|
+
box.setAttribute('data-title', title);
|
|
678
|
+
if (title)
|
|
679
|
+
box.prepend(el('legend', { class: 'fd-section-title' }, ...withIcon(undefined, title)));
|
|
680
|
+
const own = drawer({
|
|
681
|
+
root: false,
|
|
682
|
+
form: inner,
|
|
683
|
+
page: saved,
|
|
684
|
+
prefix: `${scope.prefix}${node.id}-`,
|
|
685
|
+
chain: [...scope.chain, { id: found.id, title: name }],
|
|
686
|
+
locked: () => scope.locked() || form.node(node.id).readonly,
|
|
687
|
+
path: `${scope.path}${node.name}.`,
|
|
688
|
+
});
|
|
689
|
+
const layout = saved.layout;
|
|
690
|
+
// Its parts sit in the part's box: a card there draws none of its own.
|
|
691
|
+
note.replaceWith(own.grid(layout.type === 'tabs' ? [layout] : layout.children, 1, { columns: 1, onPage: false, labels: place.labels }));
|
|
692
|
+
box.removeAttribute('aria-busy');
|
|
693
|
+
cleanups.push(inner.subscribe(renderNow));
|
|
694
|
+
if (mounted)
|
|
695
|
+
renderNow();
|
|
696
|
+
};
|
|
697
|
+
let asked;
|
|
698
|
+
try {
|
|
699
|
+
asked = (0, related_1.findPage)(options, node.version === undefined ? { id: node.page } : { id: node.page, version: node.version });
|
|
700
|
+
}
|
|
701
|
+
catch {
|
|
702
|
+
asked = null;
|
|
703
|
+
}
|
|
704
|
+
if (asked instanceof Promise)
|
|
705
|
+
asked.then(draw, () => draw(null));
|
|
706
|
+
else
|
|
707
|
+
draw(asked);
|
|
708
|
+
return box;
|
|
709
|
+
}
|
|
710
|
+
return { item, grid, fieldItem, buttonItem, hideWhen, slotItem };
|
|
329
711
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
340
|
-
/** 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. */
|
|
341
|
-
function figure(node) {
|
|
342
|
-
const image = el('img', { class: 'fd-image', src: node.src, alt: node.alt, loading: 'lazy' });
|
|
343
|
-
const href = node.href && /^(https?:\/\/|mailto:)/i.test(node.href) ? node.href : undefined;
|
|
344
|
-
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)] : []));
|
|
345
|
-
const width = typeof node.width === 'number' ? `${node.width}px` : node.width && { small: '160px', medium: '320px', large: '480px', full: '100%' }[node.width];
|
|
346
|
-
if (width)
|
|
347
|
-
element.style.setProperty('--fd-image-width', width);
|
|
348
|
-
return element;
|
|
349
|
-
}
|
|
712
|
+
const { item, grid, fieldItem, buttonItem, hideWhen, slotItem } = drawer({
|
|
713
|
+
root: true,
|
|
714
|
+
form,
|
|
715
|
+
page,
|
|
716
|
+
prefix: '',
|
|
717
|
+
chain: [{ id: page.id, title: page.title || page.id }],
|
|
718
|
+
locked: () => false,
|
|
719
|
+
path: '',
|
|
720
|
+
});
|
|
350
721
|
// ---- shared chrome ----------------------------------------------------------
|
|
351
722
|
/** The whole form locked: every field read-only, no Save; buttons still run. */
|
|
352
723
|
let locked = options.readonly === true;
|
|
@@ -368,9 +739,12 @@ function mountViewer(host, options) {
|
|
|
368
739
|
else
|
|
369
740
|
locked = false;
|
|
370
741
|
root.toggleAttribute('data-readonly', locked);
|
|
742
|
+
// Conditions read `editing`: parts for editing only, or reading only, follow.
|
|
743
|
+
form.setEditing(!locked);
|
|
371
744
|
render(form.getState());
|
|
372
745
|
});
|
|
373
746
|
root.toggleAttribute('data-readonly', locked);
|
|
747
|
+
form.setEditing(!locked);
|
|
374
748
|
updaters.push(() => {
|
|
375
749
|
if (!editSwitch)
|
|
376
750
|
return;
|
|
@@ -383,18 +757,27 @@ function mountViewer(host, options) {
|
|
|
383
757
|
retry.addEventListener('click', () => void submitOrSave());
|
|
384
758
|
const status = el('div', { class: 'fd-status-box' }, statusText, retry);
|
|
385
759
|
const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
|
|
386
|
-
/**
|
|
760
|
+
/**
|
|
761
|
+
* "Not saved. Check: Email, Phone": the fields with a problem, in the order
|
|
762
|
+
* they show; one inside a saved form with that form's title, "Street (Home address)".
|
|
763
|
+
*/
|
|
387
764
|
function checkText(state) {
|
|
388
|
-
const
|
|
765
|
+
const fieldsShown = [...root.querySelectorAll('.fd-field[data-path]')];
|
|
766
|
+
const found = [];
|
|
389
767
|
for (const key of Object.keys(state.errors)) {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
768
|
+
// The field itself, or — for a line's — the table it is in.
|
|
769
|
+
let shown;
|
|
770
|
+
for (let path = key; path && !shown; path = path.slice(0, Math.max(0, path.lastIndexOf('.')))) {
|
|
771
|
+
shown = fieldsShown.find((field) => field.getAttribute('data-path') === path && !field.hidden);
|
|
772
|
+
}
|
|
773
|
+
const first = key.split('.')[0];
|
|
774
|
+
const label = shown?.querySelector(':scope > .fd-label')?.firstChild?.textContent ?? page.fields[first]?.label ?? first;
|
|
775
|
+
const within = shown?.closest('.fd-form-part')?.getAttribute('data-title');
|
|
776
|
+
const name = within ? `${label} (${within})` : label;
|
|
777
|
+
if (!found.some((f) => f.name === name))
|
|
778
|
+
found.push({ name, at: shown ? fieldsShown.indexOf(shown) : Infinity });
|
|
779
|
+
}
|
|
780
|
+
const names = found.sort((a, b) => a.at - b.at).map((f) => f.name);
|
|
398
781
|
return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
|
|
399
782
|
}
|
|
400
783
|
let quietTimer;
|
|
@@ -459,7 +842,15 @@ function mountViewer(host, options) {
|
|
|
459
842
|
draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
|
|
460
843
|
});
|
|
461
844
|
function focusFirstProblem() {
|
|
462
|
-
|
|
845
|
+
// A problem in a tab out of sight, or not drawn yet: its tab shown first, the first such in the page's order.
|
|
846
|
+
const seen = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]'));
|
|
847
|
+
if (!seen) {
|
|
848
|
+
const errors = form.getState().errors;
|
|
849
|
+
const first = [...problemTabs.keys()].find((name) => errors[name]);
|
|
850
|
+
if (first)
|
|
851
|
+
problemTabs.get(first)?.();
|
|
852
|
+
}
|
|
853
|
+
const invalid = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]')) ?? null;
|
|
463
854
|
if (!invalid)
|
|
464
855
|
return;
|
|
465
856
|
for (let folded = invalid.closest('.fd-section-folded'); folded; folded = folded.parentElement?.closest('.fd-section-folded') ?? null) {
|
|
@@ -494,6 +885,52 @@ function mountViewer(host, options) {
|
|
|
494
885
|
content.hidden = false;
|
|
495
886
|
done.hidden = true;
|
|
496
887
|
});
|
|
888
|
+
/** The record's name, as the last of its breadcrumbs says it: its sheet's title field, else the page's title; empty for a new one. */
|
|
889
|
+
function recordName() {
|
|
890
|
+
const state = form.getState();
|
|
891
|
+
if (state.recordId == null)
|
|
892
|
+
return '';
|
|
893
|
+
const field = page.layout.type === 'sheet' ? page.layout.title?.field : undefined;
|
|
894
|
+
const name = field ? (0, widgets_1.displayValue)(page.fields[field], state.values[field], state.values, locale) : '';
|
|
895
|
+
return name || page.title || '';
|
|
896
|
+
}
|
|
897
|
+
/** The bar over a record — its breadcrumbs, its gear menu, its pager — or null when it would hold nothing. */
|
|
898
|
+
function topBar(toolbar) {
|
|
899
|
+
if (page.data.kind !== 'record')
|
|
900
|
+
return null;
|
|
901
|
+
const bar = (0, record_bar_1.recordBar)({
|
|
902
|
+
doc,
|
|
903
|
+
el,
|
|
904
|
+
form,
|
|
905
|
+
toolbar,
|
|
906
|
+
records: options.records,
|
|
907
|
+
breadcrumbs: options.breadcrumbs,
|
|
908
|
+
labels,
|
|
909
|
+
icons: options.icons,
|
|
910
|
+
uid,
|
|
911
|
+
fill,
|
|
912
|
+
withIcon,
|
|
913
|
+
press,
|
|
914
|
+
name: recordName,
|
|
915
|
+
render: renderNow,
|
|
916
|
+
async leave() {
|
|
917
|
+
if (!form.getState().dirty.length)
|
|
918
|
+
return true;
|
|
919
|
+
const saved = await form.save();
|
|
920
|
+
if (!saved) {
|
|
921
|
+
if (!form.getState().saveProblem)
|
|
922
|
+
announce(checkText(form.getState()));
|
|
923
|
+
focusFirstProblem();
|
|
924
|
+
}
|
|
925
|
+
return saved;
|
|
926
|
+
},
|
|
927
|
+
});
|
|
928
|
+
if (!bar)
|
|
929
|
+
return null;
|
|
930
|
+
updaters.push(bar.update);
|
|
931
|
+
cleanups.push(bar.destroy);
|
|
932
|
+
return bar.element;
|
|
933
|
+
}
|
|
497
934
|
function pageHead() {
|
|
498
935
|
if (!page.title && !page.description)
|
|
499
936
|
return null;
|
|
@@ -505,8 +942,20 @@ function mountViewer(host, options) {
|
|
|
505
942
|
return head;
|
|
506
943
|
}
|
|
507
944
|
// ---- the four page layouts ---------------------------------------------------
|
|
945
|
+
/**
|
|
946
|
+
* A page's own buttons at its foot, as a wizard's, in place of its Save and
|
|
947
|
+
* Discard; null when it has none, or when a dialog round it draws them.
|
|
948
|
+
*/
|
|
949
|
+
function pageFooter(footer) {
|
|
950
|
+
if (!footer?.length || options.showActions === false)
|
|
951
|
+
return null;
|
|
952
|
+
return el('div', { class: 'fd-actions fd-actions-end fd-page-footer' }, status, ...footer.map(buttonItem));
|
|
953
|
+
}
|
|
508
954
|
function sectionsLayout(node) {
|
|
509
955
|
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
|
|
956
|
+
const own = pageFooter(node.footer);
|
|
957
|
+
if (own)
|
|
958
|
+
return el('div', {}, box, own);
|
|
510
959
|
const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
|
|
511
960
|
const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
|
|
512
961
|
if (page.data.kind === 'record') {
|
|
@@ -625,7 +1074,8 @@ function mountViewer(host, options) {
|
|
|
625
1074
|
// Save and Discard lead the header bar, or close the sheet when the page puts them at its foot.
|
|
626
1075
|
const atFoot = page.actionsPosition === 'bottom';
|
|
627
1076
|
const foot = el('div', { class: 'fd-actions fd-actions-end fd-sheet-foot' });
|
|
628
|
-
|
|
1077
|
+
const own = pageFooter(node.footer);
|
|
1078
|
+
if (options.showActions === false || own)
|
|
629
1079
|
actions.append(...(node.buttons ?? []).map(buttonItem));
|
|
630
1080
|
else if (atFoot) {
|
|
631
1081
|
actions.append(...(node.buttons ?? []).map(buttonItem));
|
|
@@ -646,31 +1096,89 @@ function mountViewer(host, options) {
|
|
|
646
1096
|
if (node.statusbar && !underTitle)
|
|
647
1097
|
header.append(statusbar(node.statusbar));
|
|
648
1098
|
const card = el('div', { class: 'fd-card' });
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
1099
|
+
const ribbons = [...(node.ribbon ? [node.ribbon] : []), ...(node.ribbons ?? [])];
|
|
1100
|
+
if (ribbons.length) {
|
|
1101
|
+
// The ribbons are clipped to the card's corner by their own frame, so the card itself never clips: lists open past it.
|
|
1102
|
+
const frame = el('div', { class: 'fd-ribbon-frame' });
|
|
1103
|
+
const drawn = ribbons.map((ribbon) => {
|
|
1104
|
+
const element = el('div', { class: `fd-ribbon fd-tone-${ribbon.tone ?? 'muted'}`, 'data-node': ribbon.id, title: ribbon.tooltip }, ribbon.label);
|
|
1105
|
+
frame.append(element);
|
|
1106
|
+
return { ribbon, element };
|
|
1107
|
+
});
|
|
1108
|
+
// One corner, one ribbon: the first whose condition holds; its words from a field when the field holds any.
|
|
1109
|
+
updaters.push((state) => {
|
|
1110
|
+
let shown = false;
|
|
1111
|
+
for (const { ribbon, element } of drawn) {
|
|
1112
|
+
const show = !shown && !form.node(ribbon.id).invisible;
|
|
1113
|
+
shown ||= show;
|
|
1114
|
+
(0, dom_1.setHidden)(element, !show);
|
|
1115
|
+
if (!show)
|
|
1116
|
+
continue;
|
|
1117
|
+
const from = ribbon.labelField ? (0, widgets_1.displayValue)(page.fields[ribbon.labelField], state.values[ribbon.labelField], state.values, locale) : '';
|
|
1118
|
+
(0, dom_1.setText)(element, from || ribbon.label);
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
1121
|
+
card.append(frame);
|
|
654
1122
|
}
|
|
655
1123
|
if (node.statButtons?.length) {
|
|
656
1124
|
const stats = el('div', { class: 'fd-stats' });
|
|
1125
|
+
/** A field's value as the field shows it: money with its currency, a date, a count grouped. */
|
|
1126
|
+
const shownValue = (name, values) => name && page.fields[name] ? (0, widgets_1.displayValue)(page.fields[name], values[name], values, locale) : '';
|
|
657
1127
|
for (const stat of node.statButtons) {
|
|
658
1128
|
const value = el('span', { class: 'fd-stat-value' });
|
|
1129
|
+
const label = el('span', { class: 'fd-stat-label' }, stat.label);
|
|
1130
|
+
// A second value with words of its own: the two one over the other, each after its words.
|
|
1131
|
+
const second = stat.secondField && stat.secondLabel !== undefined ? { value: el('span', { class: 'fd-stat-value' }), label: el('span', { class: 'fd-stat-label' }, stat.secondLabel) } : null;
|
|
659
1132
|
const icon = (0, widgets_1.drawIcon)(doc, stat.icon, options.icons);
|
|
660
|
-
const words =
|
|
661
|
-
|
|
662
|
-
|
|
1133
|
+
const words = second
|
|
1134
|
+
? el('span', { class: 'fd-stat-words fd-stat-pair' }, el('span', { class: 'fd-stat-row' }, label, value), el('span', { class: 'fd-stat-row' }, second.label, second.value))
|
|
1135
|
+
: el('span', { class: 'fd-stat-words' }, value, label);
|
|
1136
|
+
// Its help, as Flectra's help=: its tooltip, and its description for a screen reader — never its name.
|
|
1137
|
+
const helpId = stat.help ? uid(`${stat.id}-help`) : undefined;
|
|
1138
|
+
const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id, title: stat.help || undefined, 'aria-describedby': helpId }, ...(icon ? [icon, words] : [words]));
|
|
1139
|
+
if (helpId)
|
|
1140
|
+
stats.append(el('span', { class: 'fd-sr-only', id: helpId }, stat.help));
|
|
1141
|
+
button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
|
|
663
1142
|
updaters.push((state) => {
|
|
664
1143
|
button.hidden = form.node(stat.id).invisible;
|
|
665
|
-
const
|
|
666
|
-
|
|
1144
|
+
const values = state.values;
|
|
1145
|
+
const unit = shownValue(stat.unitField, values) || stat.unit || '';
|
|
1146
|
+
const first = shownValue(stat.field, values);
|
|
1147
|
+
const other = shownValue(stat.secondField, values);
|
|
1148
|
+
if (second) {
|
|
1149
|
+
(0, dom_1.setText)(value, first);
|
|
1150
|
+
(0, dom_1.setText)(second.value, [other, unit].filter(Boolean).join(' '));
|
|
1151
|
+
}
|
|
1152
|
+
else
|
|
1153
|
+
(0, dom_1.setText)(value, [[first, other].filter(Boolean).join(' / '), first || other ? unit : ''].filter(Boolean).join(' '));
|
|
1154
|
+
(0, dom_1.setText)(label, shownValue(stat.labelField, values) || stat.label);
|
|
667
1155
|
});
|
|
668
1156
|
stats.append(button);
|
|
669
1157
|
}
|
|
1158
|
+
// No empty row while every button is hidden, as with the badges.
|
|
1159
|
+
updaters.push(() => {
|
|
1160
|
+
stats.hidden = [...stats.querySelectorAll(':scope > .fd-stat')].every((stat) => stat.hidden);
|
|
1161
|
+
});
|
|
670
1162
|
card.append(stats);
|
|
671
1163
|
}
|
|
672
1164
|
for (const alert of node.alerts ?? []) {
|
|
673
|
-
|
|
1165
|
+
// Its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled.
|
|
1166
|
+
const words = (0, value_words_1.valueWords)(doc, page.fields, alert.message, locale);
|
|
1167
|
+
const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
|
|
1168
|
+
let fromField = false;
|
|
1169
|
+
const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
|
|
1170
|
+
if (alert.buttons?.length)
|
|
1171
|
+
box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
|
|
1172
|
+
updaters.push((state) => {
|
|
1173
|
+
words.update(state.values);
|
|
1174
|
+
const from = alert.messageField ? state.values[alert.messageField] : null;
|
|
1175
|
+
const said = typeof from === 'string' ? from.trim() : '';
|
|
1176
|
+
if (said)
|
|
1177
|
+
(0, dom_1.setText)(message, said);
|
|
1178
|
+
else if (fromField)
|
|
1179
|
+
message.replaceChildren(...words.nodes);
|
|
1180
|
+
fromField = !!said;
|
|
1181
|
+
});
|
|
674
1182
|
if (alert.dismissible) {
|
|
675
1183
|
// Closed, it stays closed while the page is open, whatever its condition does.
|
|
676
1184
|
let dismissed = false;
|
|
@@ -702,9 +1210,26 @@ function mountViewer(host, options) {
|
|
|
702
1210
|
}
|
|
703
1211
|
if (node.title) {
|
|
704
1212
|
const title = el('div', { class: 'fd-title' });
|
|
1213
|
+
// Words over the title, as Flectra's "Product Name" over its h1, naming its box.
|
|
1214
|
+
if (node.title.label)
|
|
1215
|
+
title.append(el('label', { class: 'fd-title-label', for: uid('#title') }, node.title.label));
|
|
705
1216
|
if (node.title.above?.length)
|
|
706
1217
|
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
|
|
707
|
-
|
|
1218
|
+
const name = fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder, ...(node.title.placeholderWhen ? { placeholderWhen: node.title.placeholderWhen } : {}), ...(node.title.focus ? { focus: true } : {}) });
|
|
1219
|
+
// Fields on the title's line, before and after it: a priority star, a state's dot (Flectra's <h1>).
|
|
1220
|
+
const before = node.title.before ?? [];
|
|
1221
|
+
const after = node.title.after ?? [];
|
|
1222
|
+
if (before.length || after.length) {
|
|
1223
|
+
name.classList.add('fd-title-name');
|
|
1224
|
+
const side = (part) => {
|
|
1225
|
+
const item = fieldItem(part);
|
|
1226
|
+
item.classList.add('fd-title-side');
|
|
1227
|
+
return item;
|
|
1228
|
+
};
|
|
1229
|
+
title.append(el('div', { class: 'fd-title-line' }, ...before.map(side), name, ...after.map(side)));
|
|
1230
|
+
}
|
|
1231
|
+
else
|
|
1232
|
+
title.append(name);
|
|
708
1233
|
if (node.title.subtitleField)
|
|
709
1234
|
title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
|
|
710
1235
|
if (node.title.below?.length)
|
|
@@ -720,6 +1245,8 @@ function mountViewer(host, options) {
|
|
|
720
1245
|
if (node.statusbar && underTitle)
|
|
721
1246
|
card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
|
|
722
1247
|
card.append(grid(node.children, 1, top(false)));
|
|
1248
|
+
if (own)
|
|
1249
|
+
card.append(own);
|
|
723
1250
|
if (atFoot && options.showActions !== false) {
|
|
724
1251
|
card.append(foot);
|
|
725
1252
|
updaters.push((state) => {
|
|
@@ -727,11 +1254,22 @@ function mountViewer(host, options) {
|
|
|
727
1254
|
});
|
|
728
1255
|
}
|
|
729
1256
|
const layout = el('div', { class: 'fd-sheet-layout' }, card);
|
|
1257
|
+
if (node.attachmentPreview) {
|
|
1258
|
+
// Beside the sheet on a wide form, the side panel then under the sheet; under the sheet on a narrow one.
|
|
1259
|
+
const preview = (0, attachment_preview_1.attachmentPreview)({ doc, el, form, page, node: node.attachmentPreview, dataSource: options.dataSource, labels, fill });
|
|
1260
|
+
layout.classList.add('fd-has-preview');
|
|
1261
|
+
layout.append(preview.element);
|
|
1262
|
+
updaters.push(preview.update);
|
|
1263
|
+
cleanups.push(preview.destroy);
|
|
1264
|
+
}
|
|
730
1265
|
if (node.sidePanel) {
|
|
731
1266
|
layout.classList.add('fd-has-side');
|
|
1267
|
+
if (node.sidePanelBeside)
|
|
1268
|
+
layout.setAttribute('data-side-beside', node.sidePanelBeside);
|
|
732
1269
|
layout.append(el('aside', { class: 'fd-side' }, slotItem(node.sidePanel)));
|
|
733
1270
|
}
|
|
734
|
-
|
|
1271
|
+
const bar = topBar(node.toolbar);
|
|
1272
|
+
pageBox.append(...(bar ? [bar] : []), header, layout);
|
|
735
1273
|
return pageBox;
|
|
736
1274
|
}
|
|
737
1275
|
/** The sheet header's statusbar: the same widget a form can place anywhere. */
|
|
@@ -740,9 +1278,18 @@ function mountViewer(host, options) {
|
|
|
740
1278
|
const barOptions = { clickable: config.clickable === true };
|
|
741
1279
|
if (config.visibleStates)
|
|
742
1280
|
barOptions['visibleStates'] = config.visibleStates;
|
|
1281
|
+
for (const key of ['durationsField', 'fold', 'saves'])
|
|
1282
|
+
if (config[key] !== undefined)
|
|
1283
|
+
barOptions[key] = config[key];
|
|
743
1284
|
const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
|
|
744
1285
|
const widget = (0, widgets_1.createWidget)({ form, name: config.field, field: def, node, id: uid('statusbar'), document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
745
|
-
|
|
1286
|
+
if (widget.destroy)
|
|
1287
|
+
cleanups.push(() => widget.destroy?.());
|
|
1288
|
+
updaters.push((state) => {
|
|
1289
|
+
// Hidden while its own condition holds, or from people outside its roles.
|
|
1290
|
+
(0, dom_1.setHidden)(widget.element, form.node('#statusbar').invisible);
|
|
1291
|
+
widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false });
|
|
1292
|
+
});
|
|
746
1293
|
return widget.element;
|
|
747
1294
|
}
|
|
748
1295
|
let wizardForward = null;
|
|
@@ -765,12 +1312,13 @@ function mountViewer(host, options) {
|
|
|
765
1312
|
labels,
|
|
766
1313
|
locale,
|
|
767
1314
|
fill,
|
|
768
|
-
|
|
1315
|
+
press,
|
|
769
1316
|
withIcon,
|
|
770
1317
|
uid,
|
|
771
1318
|
icons: options.icons,
|
|
772
1319
|
preferences,
|
|
773
1320
|
onOpenRecord: options.onOpenRecord,
|
|
1321
|
+
...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
|
|
774
1322
|
});
|
|
775
1323
|
cleanups.push(list.destroy);
|
|
776
1324
|
body = list.element;
|
|
@@ -778,7 +1326,9 @@ function mountViewer(host, options) {
|
|
|
778
1326
|
else
|
|
779
1327
|
body = sectionsLayout(layout);
|
|
780
1328
|
const head = layout.type === 'sheet' ? null : pageHead();
|
|
781
|
-
|
|
1329
|
+
// A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
|
|
1330
|
+
const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
|
|
1331
|
+
content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
|
|
782
1332
|
root.append(content, done, announcer);
|
|
783
1333
|
// The status leaves its place beside Save when the page wants it in a corner or across the top.
|
|
784
1334
|
if (options.saveStatus === 'toast') {
|
|
@@ -853,6 +1403,46 @@ function mountViewer(host, options) {
|
|
|
853
1403
|
event.preventDefault();
|
|
854
1404
|
nextField(from)?.focus();
|
|
855
1405
|
});
|
|
1406
|
+
// ---- a button's key, with Alt -------------------------------------------------
|
|
1407
|
+
/**
|
|
1408
|
+
* Whether a key pressed with Alt is this form's: pressed in it (not in a
|
|
1409
|
+
* form inside it, such as a dialog's), or — pressed outside any form — this
|
|
1410
|
+
* is the first form on the page, or in the question or dialog on top of it.
|
|
1411
|
+
*/
|
|
1412
|
+
function hotkeysHere(target) {
|
|
1413
|
+
const inner = target instanceof Element ? target.closest('.fd-form') : null;
|
|
1414
|
+
if (inner)
|
|
1415
|
+
return inner === root && !root.querySelector(':scope > .fd-dialog-backdrop');
|
|
1416
|
+
const modals = [...doc.querySelectorAll('[aria-modal="true"]')].filter((modal) => !modal.closest('[hidden]'));
|
|
1417
|
+
const top = modals[modals.length - 1];
|
|
1418
|
+
return (top ?? doc).querySelector('.fd-form') === root;
|
|
1419
|
+
}
|
|
1420
|
+
const onHotkey = (event) => {
|
|
1421
|
+
if (event.key === 'Alt') {
|
|
1422
|
+
if (hotkeysHere(event.target))
|
|
1423
|
+
root.setAttribute('data-hotkeys', '');
|
|
1424
|
+
return;
|
|
1425
|
+
}
|
|
1426
|
+
const key = (0, hotkeys_1.hotkeyOf)(event);
|
|
1427
|
+
if (!key || !hotkeysHere(event.target))
|
|
1428
|
+
return;
|
|
1429
|
+
const button = [...root.querySelectorAll(`button[data-hotkey="${key}"]`)].find((b) => b.closest('.fd-form') === root && !b.closest('[hidden]') && !b.disabled && !b.hasAttribute('aria-busy'));
|
|
1430
|
+
if (!button)
|
|
1431
|
+
return;
|
|
1432
|
+
event.preventDefault();
|
|
1433
|
+
root.removeAttribute('data-hotkeys');
|
|
1434
|
+
button.click();
|
|
1435
|
+
};
|
|
1436
|
+
const offHotkeys = () => root.removeAttribute('data-hotkeys');
|
|
1437
|
+
const altUp = (event) => event.key === 'Alt' && offHotkeys();
|
|
1438
|
+
doc.addEventListener('keydown', onHotkey);
|
|
1439
|
+
doc.addEventListener('keyup', altUp);
|
|
1440
|
+
doc.defaultView?.addEventListener('blur', offHotkeys);
|
|
1441
|
+
cleanups.push(() => {
|
|
1442
|
+
doc.removeEventListener('keydown', onHotkey);
|
|
1443
|
+
doc.removeEventListener('keyup', altUp);
|
|
1444
|
+
doc.defaultView?.removeEventListener('blur', offHotkeys);
|
|
1445
|
+
});
|
|
856
1446
|
// ---- confirmation dialog -----------------------------------------------------
|
|
857
1447
|
/** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
|
|
858
1448
|
function dialogConfirm(message, withCancel = true) {
|
|
@@ -874,9 +1464,13 @@ function mountViewer(host, options) {
|
|
|
874
1464
|
};
|
|
875
1465
|
cancel.addEventListener('click', () => close(false));
|
|
876
1466
|
ok.addEventListener('click', () => close(true));
|
|
1467
|
+
// Its keys are its own: Escape answers No here, and closes no dialog or panel under it.
|
|
877
1468
|
backdrop.addEventListener('keydown', (event) => {
|
|
878
|
-
|
|
1469
|
+
event.stopPropagation();
|
|
1470
|
+
if (event.key === 'Escape') {
|
|
1471
|
+
event.preventDefault();
|
|
879
1472
|
close(false);
|
|
1473
|
+
}
|
|
880
1474
|
else
|
|
881
1475
|
(0, focus_trap_1.keepTabIn)(dialog, event);
|
|
882
1476
|
});
|
|
@@ -891,7 +1485,31 @@ function mountViewer(host, options) {
|
|
|
891
1485
|
}
|
|
892
1486
|
host.append(root);
|
|
893
1487
|
const unsubscribe = form.subscribe(render);
|
|
1488
|
+
// 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.
|
|
1489
|
+
cleanups.push(form.on('run', ({ result }) => {
|
|
1490
|
+
if (result.reason === 'cannot' || (result.reason === 'app' && result.error !== undefined))
|
|
1491
|
+
actionHost.say(result.message ?? '', result.reason === 'app' ? 'danger' : 'warning');
|
|
1492
|
+
}));
|
|
894
1493
|
render(form.getState());
|
|
1494
|
+
mounted = true;
|
|
1495
|
+
// As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
|
|
1496
|
+
// only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
|
|
1497
|
+
let opened = false;
|
|
1498
|
+
if (focusers.length) {
|
|
1499
|
+
cleanups.push(form.on('open', () => {
|
|
1500
|
+
const first = !opened;
|
|
1501
|
+
opened = true;
|
|
1502
|
+
queueMicrotask(() => {
|
|
1503
|
+
if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
|
|
1504
|
+
return;
|
|
1505
|
+
const active = doc.activeElement;
|
|
1506
|
+
const free = !active || active === doc.body || active === doc.documentElement;
|
|
1507
|
+
if (!free && !(first && root.contains(active)))
|
|
1508
|
+
return;
|
|
1509
|
+
focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
|
|
1510
|
+
});
|
|
1511
|
+
}));
|
|
1512
|
+
}
|
|
895
1513
|
if (form.getState().status === 'idle')
|
|
896
1514
|
void form.load();
|
|
897
1515
|
return {
|
|
@@ -915,10 +1533,19 @@ function mountViewer(host, options) {
|
|
|
915
1533
|
setReadonly(readonly) {
|
|
916
1534
|
locked = readonly;
|
|
917
1535
|
root.toggleAttribute('data-readonly', locked);
|
|
1536
|
+
form.setEditing(!locked);
|
|
918
1537
|
render(form.getState());
|
|
919
1538
|
},
|
|
920
1539
|
isReadonly: () => locked,
|
|
1540
|
+
on: (event, listener) => {
|
|
1541
|
+
const off = form.on(event, listener);
|
|
1542
|
+
cleanups.push(off);
|
|
1543
|
+
return off;
|
|
1544
|
+
},
|
|
1545
|
+
setValues: (values) => form.setValues(values),
|
|
1546
|
+
run: (steps, context) => form.run(steps, context),
|
|
921
1547
|
destroy() {
|
|
1548
|
+
gone = true;
|
|
922
1549
|
unsubscribe();
|
|
923
1550
|
for (const cleanup of cleanups)
|
|
924
1551
|
cleanup();
|