@fieldia/viewer 0.11.0 → 0.13.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/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 +62 -4
- package/bundle/fieldia.js.map +4 -4
- package/package.json +3 -3
- package/src/index.d.mts +1 -1
- package/src/index.d.ts +1 -1
- package/src/index.js +5 -1
- package/src/index.js.map +1 -1
- package/src/index.mjs +1 -1
- package/src/index.mjs.map +1 -1
- 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 +8 -1
- package/src/lib/dialog.d.ts +8 -1
- package/src/lib/dialog.js +75 -10
- package/src/lib/dialog.js.map +1 -1
- package/src/lib/dialog.mjs +75 -11
- 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 +25 -0
- package/src/lib/labels.d.ts +25 -0
- package/src/lib/labels.js +17 -0
- package/src/lib/labels.js.map +1 -1
- package/src/lib/labels.mjs +17 -0
- package/src/lib/labels.mjs.map +1 -1
- package/src/lib/list.d.mts +3 -1
- package/src/lib/list.d.ts +3 -1
- package/src/lib/list.js +1 -1
- package/src/lib/list.js.map +1 -1
- package/src/lib/list.mjs +1 -1
- package/src/lib/list.mjs.map +1 -1
- package/src/lib/locales/ar.js +17 -0
- package/src/lib/locales/ar.js.map +1 -1
- package/src/lib/locales/ar.mjs +17 -0
- package/src/lib/locales/ar.mjs.map +1 -1
- package/src/lib/locales/de.js +17 -0
- package/src/lib/locales/de.js.map +1 -1
- package/src/lib/locales/de.mjs +17 -0
- package/src/lib/locales/de.mjs.map +1 -1
- package/src/lib/locales/fr.js +17 -0
- package/src/lib/locales/fr.js.map +1 -1
- package/src/lib/locales/fr.mjs +17 -0
- package/src/lib/locales/fr.mjs.map +1 -1
- package/src/lib/look.d.mts +16 -1
- package/src/lib/look.d.ts +16 -1
- package/src/lib/look.js +34 -0
- package/src/lib/look.js.map +1 -1
- package/src/lib/look.mjs +31 -0
- package/src/lib/look.mjs.map +1 -1
- package/src/lib/open.d.mts +7 -1
- package/src/lib/open.d.ts +7 -1
- package/src/lib/open.js +19 -8
- package/src/lib/open.js.map +1 -1
- package/src/lib/open.mjs +20 -9
- package/src/lib/open.mjs.map +1 -1
- 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 +7 -3
- package/src/lib/related.d.ts +7 -3
- package/src/lib/related.js +28 -9
- package/src/lib/related.js.map +1 -1
- package/src/lib/related.mjs +28 -9
- package/src/lib/related.mjs.map +1 -1
- package/src/lib/say.d.mts +3 -1
- package/src/lib/say.d.ts +3 -1
- package/src/lib/say.js +17 -1
- package/src/lib/say.js.map +1 -1
- package/src/lib/say.mjs +17 -1
- package/src/lib/say.mjs.map +1 -1
- 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 +45 -2
- package/src/lib/viewer.d.ts +45 -2
- package/src/lib/viewer.js +575 -51
- package/src/lib/viewer.js.map +1 -1
- package/src/lib/viewer.mjs +576 -53
- package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.DEFAULT_LABELS = exports.VIEWER_LABELS = void 0;
|
|
4
4
|
exports.mountViewer = mountViewer;
|
|
5
|
+
exports.onelineRuns = onelineRuns;
|
|
5
6
|
const core_1 = require("@fieldia/core");
|
|
6
7
|
const widgets_1 = require("@fieldia/widgets");
|
|
7
8
|
const related_1 = require("./related");
|
|
@@ -11,6 +12,12 @@ const place_1 = require("./place");
|
|
|
11
12
|
const dom_1 = require("./dom");
|
|
12
13
|
const open_1 = require("./open");
|
|
13
14
|
const say_1 = require("./say");
|
|
15
|
+
const tab_strip_1 = require("./tab-strip");
|
|
16
|
+
const value_words_1 = require("./value-words");
|
|
17
|
+
const hotkeys_1 = require("./hotkeys");
|
|
18
|
+
const record_bar_1 = require("./record-bar");
|
|
19
|
+
const attachment_preview_1 = require("./attachment-preview");
|
|
20
|
+
const record_styles_1 = require("./record-styles");
|
|
14
21
|
const labels_1 = require("./labels");
|
|
15
22
|
const focus_trap_1 = require("./focus-trap");
|
|
16
23
|
var labels_2 = require("./labels");
|
|
@@ -22,10 +29,13 @@ let mounts = 0;
|
|
|
22
29
|
* React, Angular and Vue bindings are thin shells around this function.
|
|
23
30
|
* Throws when the page does not validate, listing every problem.
|
|
24
31
|
*/
|
|
25
|
-
function mountViewer(host,
|
|
32
|
+
function mountViewer(host, given) {
|
|
33
|
+
// Its own copy: setTokens changes what the dialogs it opens later wear, never the app's object.
|
|
34
|
+
const options = { ...given };
|
|
26
35
|
const doc = host.ownerDocument;
|
|
27
36
|
// The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
|
|
28
|
-
|
|
37
|
+
// A line's own form reads the record the line is on as parent.
|
|
38
|
+
const checked = (0, core_1.checkPage)(options.page, options.parent ? { parent: options.parent.fields } : {});
|
|
29
39
|
if (!checked.ok) {
|
|
30
40
|
throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
|
|
31
41
|
}
|
|
@@ -40,7 +50,7 @@ function mountViewer(host, options) {
|
|
|
40
50
|
};
|
|
41
51
|
const locale = (0, labels_1.ownLocale)(tag);
|
|
42
52
|
const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
|
|
43
|
-
const dialogs = (0, related_1.pageDialogs)(options);
|
|
53
|
+
const dialogs = (0, related_1.pageDialogs)(options, (message, tone) => actionHost.say(message, tone));
|
|
44
54
|
const ownsForm = !options.form;
|
|
45
55
|
const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
|
|
46
56
|
/**
|
|
@@ -49,7 +59,17 @@ function mountViewer(host, options) {
|
|
|
49
59
|
* made elsewhere (`form`) keeps the host it was made with.
|
|
50
60
|
*/
|
|
51
61
|
const actionHost = {
|
|
52
|
-
open: (request) => (0, open_1.openPage)({
|
|
62
|
+
open: (request) => (0, open_1.openPage)({
|
|
63
|
+
options,
|
|
64
|
+
place: host,
|
|
65
|
+
root,
|
|
66
|
+
title: (other) => shownAs(other).title || other.id,
|
|
67
|
+
back: labels.back,
|
|
68
|
+
missing: (id) => fill(labels.pageMissing, { page: id }),
|
|
69
|
+
say: (message, tone) => actionHost.say(message, tone),
|
|
70
|
+
post: (message) => form.post(message),
|
|
71
|
+
name: () => recordName() || labels.newRecord,
|
|
72
|
+
}, request),
|
|
53
73
|
say: (message, tone) => say(message, tone),
|
|
54
74
|
ask: (message) => confirm(message),
|
|
55
75
|
show: (target) => {
|
|
@@ -57,6 +77,8 @@ function mountViewer(host, options) {
|
|
|
57
77
|
if (!show?.())
|
|
58
78
|
throw new Error(`No tab "${target}" can be shown here`);
|
|
59
79
|
},
|
|
80
|
+
// A new tab opened apart from this page, or this one sent there.
|
|
81
|
+
openUrl: (url, newTab) => (newTab ? doc.defaultView?.open(url, '_blank', 'noopener,noreferrer') : doc.defaultView?.location.assign(url)),
|
|
60
82
|
...options.host,
|
|
61
83
|
};
|
|
62
84
|
const form = options.form ?? (0, core_1.createForm)({ ...options, page, host: actionHost, messages: options.messages ?? core_1.MESSAGES[locale] });
|
|
@@ -64,11 +86,17 @@ function mountViewer(host, options) {
|
|
|
64
86
|
const dir = options.dir ?? (tag && (0, core_1.isRightToLeft)(tag) ? 'rtl' : undefined);
|
|
65
87
|
const prefix = `fd${++mounts}`;
|
|
66
88
|
const updaters = [];
|
|
89
|
+
/**
|
|
90
|
+
* Where a part's updater goes as it is drawn: the form's own list, or the list
|
|
91
|
+
* of the tab it is drawn in, which runs only while that tab is shown.
|
|
92
|
+
*/
|
|
93
|
+
let sink = updaters;
|
|
67
94
|
const cleanups = [];
|
|
68
95
|
const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
69
96
|
const uid = ownUid;
|
|
70
97
|
const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
|
|
71
98
|
(0, widgets_1.installStyles)(doc);
|
|
99
|
+
(0, record_styles_1.installRecordStyles)(doc);
|
|
72
100
|
function el(tag, attrs = {}, ...children) {
|
|
73
101
|
const node = doc.createElement(tag);
|
|
74
102
|
for (const [name, value] of Object.entries(attrs))
|
|
@@ -87,12 +115,16 @@ function mountViewer(host, options) {
|
|
|
87
115
|
const words = el('bdi', {}, text);
|
|
88
116
|
return icon ? [icon, words] : [words];
|
|
89
117
|
}
|
|
90
|
-
const
|
|
91
|
-
(0, look_1.
|
|
118
|
+
const theme = options.theme ?? page.look?.theme;
|
|
119
|
+
const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': (0, look_1.skinFor)(options.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
|
|
120
|
+
(0, look_1.applyLook)(root, theme ? { ...page.look, theme } : page.look);
|
|
121
|
+
(0, look_1.wearTokens)(root, options.tokens);
|
|
92
122
|
const confirm = options.confirm ?? dialogConfirm;
|
|
93
123
|
const say = (0, say_1.sayer)(root, el, labels.dismiss);
|
|
94
124
|
/** The tabs a step can show, by id: each shows its tab, false when it is hidden. */
|
|
95
125
|
const tabs = new Map();
|
|
126
|
+
/** For each field in a tab, how to show its tab: a problem there is shown before the focus goes to it. */
|
|
127
|
+
const problemTabs = new Map();
|
|
96
128
|
/**
|
|
97
129
|
* A press runs its steps one run at a time: the button busy meanwhile, and
|
|
98
130
|
* not pressed again. A check or a save that stops it takes the focus to the
|
|
@@ -117,6 +149,8 @@ function mountViewer(host, options) {
|
|
|
117
149
|
}
|
|
118
150
|
}
|
|
119
151
|
// ---- the parts -------------------------------------------------------
|
|
152
|
+
/** The fields that take the focus as the record opens, in the order they are drawn. */
|
|
153
|
+
const focusers = [];
|
|
120
154
|
/** Folded sections, and how to open each: a problem inside one has to be seen. */
|
|
121
155
|
const folds = new Map();
|
|
122
156
|
/** Every part drawn again from the state of the page shown. */
|
|
@@ -139,17 +173,17 @@ function mountViewer(host, options) {
|
|
|
139
173
|
const { form, page } = scope;
|
|
140
174
|
const uid = (id) => ownUid(scope.prefix + id);
|
|
141
175
|
/** A part's updater, handed the state of the part's own form. */
|
|
142
|
-
const watch = (update) =>
|
|
143
|
-
/** A field with its label, help and messages. `
|
|
144
|
-
function fieldItem(node,
|
|
176
|
+
const watch = (update) => sink.push(scope.root ? update : () => update(form.getState()));
|
|
177
|
+
/** A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none. */
|
|
178
|
+
function fieldItem(node, place) {
|
|
145
179
|
const def = page.fields[node.field];
|
|
146
180
|
const id = uid(node.id);
|
|
147
|
-
const labelsAt = (0, place_1.labelPlace)(node, def.type,
|
|
148
|
-
const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-path': scope.path + node.field, 'data-type': def.type, 'data-labels': labelsAt });
|
|
181
|
+
const labelsAt = (0, place_1.labelPlace)(node, def.type, place);
|
|
182
|
+
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 });
|
|
149
183
|
if (node.colspan)
|
|
150
184
|
wrapper.style.setProperty('--fd-span', String(node.colspan));
|
|
151
185
|
const labelText = node.label ?? def.label;
|
|
152
|
-
const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
|
|
186
|
+
const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, keepMarkWithWord(labelText));
|
|
153
187
|
// The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
|
|
154
188
|
const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
|
|
155
189
|
if (mark) {
|
|
@@ -157,11 +191,16 @@ function mountViewer(host, options) {
|
|
|
157
191
|
label.append(mark);
|
|
158
192
|
}
|
|
159
193
|
const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
194
|
+
// 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.
|
|
195
|
+
if (node.focus && scope.root)
|
|
196
|
+
focusers.push({ wrapper, focus: () => widget.focus() });
|
|
160
197
|
// A label kept out of sight still names the box; the empty box shows it instead, unless the page gives it words of its own.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
198
|
+
const textBox = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
|
|
199
|
+
if (labelsAt === 'hidden' && !node.placeholder)
|
|
200
|
+
textBox?.setAttribute('placeholder', labelText);
|
|
201
|
+
// Words chosen by a condition (placeholderWhen), else what the box showed as it was drawn.
|
|
202
|
+
const drawnPlaceholder = textBox?.getAttribute('placeholder') ?? null;
|
|
203
|
+
const chosenPlaceholder = node.placeholderWhen?.length ? textBox : null;
|
|
165
204
|
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
|
|
166
205
|
// `for` stays: a custom field that puts the id on its own input is
|
|
167
206
|
// labelled natively. Only a wrapper with a role (a radio group, say) may
|
|
@@ -169,15 +208,34 @@ function mountViewer(host, options) {
|
|
|
169
208
|
// A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
|
|
170
209
|
const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
|
|
171
210
|
named?.setAttribute('aria-labelledby', label.id);
|
|
172
|
-
|
|
211
|
+
// Not a press on its (?) or the help bubble beside it: they show the help, and must not open a picker's list.
|
|
212
|
+
label.addEventListener('click', (event) => {
|
|
213
|
+
if (!event.target.closest?.('.fd-help-tip-wrap'))
|
|
214
|
+
widget.focus();
|
|
215
|
+
});
|
|
173
216
|
}
|
|
174
217
|
const helpText = node.help ?? def.help;
|
|
175
|
-
|
|
218
|
+
// Under the field, behind a (?) by its label, or both: the field's own way, else the page's.
|
|
219
|
+
const helpWay = node.helpShown ?? page.look?.helpShown ?? 'below';
|
|
220
|
+
const help = helpText && helpWay !== 'tooltip' ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
|
|
221
|
+
const tip = helpText && helpWay !== 'below' ? helpTip(`${id}-tip`, helpText, fill(labels.helpFor, { label: labelText })) : null;
|
|
222
|
+
// 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.
|
|
223
|
+
if (tip && labelsAt === 'hidden')
|
|
224
|
+
widget.element.after(tip.element);
|
|
225
|
+
// Its side of the words and the gap before it follow the way the words run: English on an Arabic page too.
|
|
226
|
+
else if (tip) {
|
|
227
|
+
label.append(tip.element);
|
|
228
|
+
label.dir = 'auto';
|
|
229
|
+
}
|
|
176
230
|
// Not an alert of its own: a refused save is announced once, naming every field to look at.
|
|
177
231
|
const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
|
|
178
232
|
// A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
|
|
179
233
|
const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
|
|
180
234
|
wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
|
|
235
|
+
// Read-only, as words in place of the box, when the page draws them so.
|
|
236
|
+
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;
|
|
237
|
+
if (asText)
|
|
238
|
+
widget.element.after(asText.element);
|
|
181
239
|
if (widget.destroy)
|
|
182
240
|
cleanups.push(() => widget.destroy?.());
|
|
183
241
|
// An answer rule's warning waits until the person leaves the field: no advice
|
|
@@ -193,10 +251,19 @@ function mountViewer(host, options) {
|
|
|
193
251
|
// (a deleted line's button), and that redraw must finish first.
|
|
194
252
|
queueMicrotask(() => update(form.getState()));
|
|
195
253
|
});
|
|
254
|
+
const toned = node.tones !== undefined || node.bold !== undefined;
|
|
196
255
|
const update = (state) => {
|
|
197
256
|
const shown = form.node(node.id);
|
|
198
257
|
(0, dom_1.setHidden)(wrapper, shown.invisible);
|
|
199
258
|
wrapper.classList.toggle('fd-required', shown.required);
|
|
259
|
+
// Its value's tone and bold while a condition holds: Flectra's decoration-* on a field.
|
|
260
|
+
if (toned) {
|
|
261
|
+
const look = form.fieldTone(node.id);
|
|
262
|
+
(0, dom_1.setAttr)(wrapper, 'data-tone', look.tone);
|
|
263
|
+
wrapper.classList.toggle('fd-value-bold', look.bold);
|
|
264
|
+
}
|
|
265
|
+
if (chosenPlaceholder)
|
|
266
|
+
(0, dom_1.setAttr)(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? drawnPlaceholder);
|
|
200
267
|
const message = state.errors[node.field];
|
|
201
268
|
(0, dom_1.setHidden)(error, !message);
|
|
202
269
|
(0, dom_1.setText)(error, message ?? '');
|
|
@@ -214,34 +281,92 @@ function mountViewer(host, options) {
|
|
|
214
281
|
wrapper.classList.toggle('fd-valid', valid);
|
|
215
282
|
mark.toggleAttribute('hidden', !valid);
|
|
216
283
|
}
|
|
284
|
+
const readonly = shown.readonly || locked || scope.locked();
|
|
285
|
+
if (asText) {
|
|
286
|
+
(0, dom_1.setHidden)(widget.element, readonly);
|
|
287
|
+
(0, dom_1.setHidden)(asText.element, !readonly);
|
|
288
|
+
(0, dom_1.setAttr)(wrapper, 'data-read-text', readonly ? '' : null);
|
|
289
|
+
if (readonly)
|
|
290
|
+
asText.update(state.values[node.field], state.values);
|
|
291
|
+
}
|
|
217
292
|
widget.update({
|
|
218
293
|
value: state.values[node.field],
|
|
219
294
|
values: state.values,
|
|
220
|
-
readonly
|
|
295
|
+
readonly,
|
|
221
296
|
required: shown.required,
|
|
222
297
|
invalid: !!message,
|
|
223
|
-
describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
298
|
+
describedBy: [help?.id ?? tip?.bubble.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
224
299
|
});
|
|
225
300
|
};
|
|
226
301
|
watch(update);
|
|
227
302
|
return wrapper;
|
|
228
303
|
}
|
|
304
|
+
/** 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. */
|
|
305
|
+
function helpTip(bubbleId, text, name) {
|
|
306
|
+
const bubble = el('span', { class: 'fd-help-bubble', role: 'tooltip', id: bubbleId, hidden: '' }, text);
|
|
307
|
+
const button = el('button', { type: 'button', class: 'fd-help-tip', 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, '?');
|
|
308
|
+
let kept = false;
|
|
309
|
+
const show = (open) => {
|
|
310
|
+
bubble.hidden = !open;
|
|
311
|
+
button.setAttribute('aria-expanded', String(open));
|
|
312
|
+
if (open)
|
|
313
|
+
keepOnScreen(bubble);
|
|
314
|
+
};
|
|
315
|
+
button.addEventListener('mouseenter', () => show(true));
|
|
316
|
+
button.addEventListener('mouseleave', () => show(kept));
|
|
317
|
+
button.addEventListener('focus', () => show(true));
|
|
318
|
+
button.addEventListener('blur', () => ((kept = false), show(false)));
|
|
319
|
+
button.addEventListener('click', (event) => {
|
|
320
|
+
event.preventDefault();
|
|
321
|
+
kept = !kept;
|
|
322
|
+
show(kept);
|
|
323
|
+
});
|
|
324
|
+
button.addEventListener('keydown', (event) => {
|
|
325
|
+
if (event.key !== 'Escape' || bubble.hidden)
|
|
326
|
+
return;
|
|
327
|
+
// Escape closes the bubble, not the dialog the form is in.
|
|
328
|
+
event.stopPropagation();
|
|
329
|
+
kept = false;
|
|
330
|
+
show(false);
|
|
331
|
+
});
|
|
332
|
+
return { element: el('span', { class: 'fd-help-tip-wrap' }, button, bubble), bubble };
|
|
333
|
+
}
|
|
334
|
+
/** A bubble hangs from its (?): near the edge of a phone's screen it runs past it, so it is moved back in. */
|
|
335
|
+
function keepOnScreen(bubble) {
|
|
336
|
+
bubble.style.transform = '';
|
|
337
|
+
const at = bubble.getBoundingClientRect();
|
|
338
|
+
const width = doc.documentElement.clientWidth;
|
|
339
|
+
const margin = 8;
|
|
340
|
+
const shift = at.right > width - margin ? width - margin - at.right : at.left < margin ? margin - at.left : 0;
|
|
341
|
+
if (shift)
|
|
342
|
+
bubble.style.transform = `translateX(${Math.round(shift)}px)`;
|
|
343
|
+
}
|
|
229
344
|
function hideWhen(element, id) {
|
|
230
345
|
watch(() => (0, dom_1.setHidden)(element, form.node(id).invisible));
|
|
231
346
|
}
|
|
232
347
|
function buttonItem(node) {
|
|
233
348
|
const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
|
|
349
|
+
if (node.hotkey)
|
|
350
|
+
(0, hotkeys_1.hotkeyOn)(button, node.hotkey, node.label);
|
|
234
351
|
spans(button, node.colspan);
|
|
235
352
|
// Its confirmation, then its steps: the form asks, through the viewer.
|
|
236
353
|
button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
|
|
237
354
|
hideWhen(button, node.id);
|
|
238
|
-
|
|
355
|
+
// A header's button is drawn here too, never through item(): hidden at its widths the same way.
|
|
356
|
+
return hiddenOn(node, button);
|
|
239
357
|
}
|
|
240
|
-
function textItem(node) {
|
|
358
|
+
function textItem(node, inline = false) {
|
|
241
359
|
const style = node.style ?? 'paragraph';
|
|
242
|
-
|
|
360
|
+
// Its words, with the values of the fields they name.
|
|
361
|
+
const words = (0, value_words_1.valueWords)(doc, page.fields, node.text, locale);
|
|
362
|
+
const element = inline
|
|
363
|
+
? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...words.nodes)
|
|
364
|
+
: style === 'alert'
|
|
365
|
+
? 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))
|
|
366
|
+
: el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...words.nodes);
|
|
243
367
|
spans(element, node.colspan);
|
|
244
368
|
hideWhen(element, node.id);
|
|
369
|
+
watch((state) => words.update(state.values));
|
|
245
370
|
return element;
|
|
246
371
|
}
|
|
247
372
|
function slotItem(node) {
|
|
@@ -260,6 +385,9 @@ function mountViewer(host, options) {
|
|
|
260
385
|
const box = el('div', { class: 'fd-grid' });
|
|
261
386
|
if (columns !== null) {
|
|
262
387
|
box.style.setProperty('--fd-columns', String((0, core_1.wideColumns)(columns)));
|
|
388
|
+
// Twelfths: eleven gaps between them, which shrink in a narrow column rather than push past it.
|
|
389
|
+
if ((0, core_1.wideColumns)(columns) === 12)
|
|
390
|
+
box.setAttribute('data-twelfths', '');
|
|
263
391
|
// Counts given for the narrower widths replace the skin's own stacking there.
|
|
264
392
|
if (typeof columns === 'object') {
|
|
265
393
|
for (const width of ['medium', 'narrow']) {
|
|
@@ -274,7 +402,47 @@ function mountViewer(host, options) {
|
|
|
274
402
|
box.append(...children.map((child) => item(child, place)));
|
|
275
403
|
return box;
|
|
276
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* A row of twelfths led by a field whose label sits beside it, as Flectra's
|
|
407
|
+
* `<label/><div class="o_row">`: that label goes with the labels round it,
|
|
408
|
+
* and the parts share the room their values have.
|
|
409
|
+
*/
|
|
410
|
+
function labelledRow(node, plan) {
|
|
411
|
+
const [first] = node.children;
|
|
412
|
+
if (!plan.arrangement || plan.at === 'tracks' || (0, core_1.wideColumns)(node.columns) !== 12 || node.children.length < 2 || first?.type !== 'field')
|
|
413
|
+
return null;
|
|
414
|
+
// One line of them: parts running past twelve twelfths are a column of fields, each with its own label.
|
|
415
|
+
if (node.children.reduce((sum, child) => sum + (child.colspan ?? 1), 0) > 12)
|
|
416
|
+
return null;
|
|
417
|
+
const type = page.fields[first.field]?.type;
|
|
418
|
+
const where = type ? (0, place_1.labelPlace)(first, type, plan.inner.labels) : 'hidden';
|
|
419
|
+
// The skin's place: beside in the underline skin, above in the outlined one, as the stylesheet says.
|
|
420
|
+
return where === undefined ? 'skin' : where === 'beside' ? 'beside' : null;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* A line of parts, as Flectra's `<label/><div class="o_row">`: its title as
|
|
424
|
+
* the line's label, where the labels round it sit, and its parts after it,
|
|
425
|
+
* each as wide as it needs, its fields named for a screen reader.
|
|
426
|
+
*/
|
|
427
|
+
function inlineItem(node, place) {
|
|
428
|
+
const line = el('div', { class: 'fd-field fd-oneline', 'data-node': node.id, 'data-style': 'inline', 'data-labels': place.labels });
|
|
429
|
+
spans(line, node.colspan);
|
|
430
|
+
const row = el('div', { class: 'fd-oneline-row', role: 'group' }, ...onelineRuns(node.children).map((run) => {
|
|
431
|
+
const parts = run.map((child) => item(child, { columns: 1, onPage: false, inline: true }));
|
|
432
|
+
return parts.length === 1 ? parts[0] : el('span', { class: 'fd-oneline-pair' }, ...parts);
|
|
433
|
+
}));
|
|
434
|
+
if (node.title) {
|
|
435
|
+
const title = el('span', { class: 'fd-label', id: uid(`${node.id}-label`) }, node.title);
|
|
436
|
+
row.setAttribute('aria-labelledby', title.id);
|
|
437
|
+
line.append(title);
|
|
438
|
+
}
|
|
439
|
+
line.append(row);
|
|
440
|
+
hideWhen(line, node.id);
|
|
441
|
+
return line;
|
|
442
|
+
}
|
|
277
443
|
function sectionItem(node, place) {
|
|
444
|
+
if (node.style === 'inline')
|
|
445
|
+
return inlineItem(node, place);
|
|
278
446
|
const plan = (0, place_1.planSection)(node, place);
|
|
279
447
|
// An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
|
|
280
448
|
const section = el(plan.arrangement ? 'div' : 'fieldset', {
|
|
@@ -285,6 +453,9 @@ function mountViewer(host, options) {
|
|
|
285
453
|
'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
|
|
286
454
|
});
|
|
287
455
|
spans(section, node.colspan);
|
|
456
|
+
const row = labelledRow(node, plan);
|
|
457
|
+
if (row)
|
|
458
|
+
section.setAttribute('data-row', row);
|
|
288
459
|
if (node.labelWidth)
|
|
289
460
|
section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
|
|
290
461
|
const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
|
|
@@ -333,13 +504,39 @@ function mountViewer(host, options) {
|
|
|
333
504
|
const tabId = uid(`${tab.id}-tab`);
|
|
334
505
|
const panelId = uid(`${tab.id}-panel`);
|
|
335
506
|
const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, ...withIcon(tab.icon, tab.label));
|
|
336
|
-
|
|
337
|
-
|
|
507
|
+
const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId });
|
|
508
|
+
/**
|
|
509
|
+
* A tab's parts are drawn when it is first shown, and kept up to date
|
|
510
|
+
* only while it is shown: a page of many tabs costs one tab's work.
|
|
511
|
+
*/
|
|
512
|
+
const own = [];
|
|
513
|
+
let built = false;
|
|
514
|
+
const build = () => {
|
|
515
|
+
if (built)
|
|
516
|
+
return;
|
|
517
|
+
built = true;
|
|
518
|
+
const outer = sink;
|
|
519
|
+
sink = own;
|
|
520
|
+
try {
|
|
521
|
+
// A tab's parts sit where the tabs do: on the page, or in the box round them.
|
|
522
|
+
panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
|
|
523
|
+
}
|
|
524
|
+
finally {
|
|
525
|
+
sink = outer;
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
const open = () => !form.node(tab.id).invisible && (show(tab.id), true);
|
|
338
529
|
button.addEventListener('click', () => show(tab.id));
|
|
339
530
|
if (!tabs.has(tab.id))
|
|
340
|
-
tabs.set(tab.id,
|
|
531
|
+
tabs.set(tab.id, open);
|
|
532
|
+
// The fields it holds, to mark it while one has a problem and to open it at one.
|
|
533
|
+
const fields = fieldsIn(tab.children);
|
|
534
|
+
if (scope.root)
|
|
535
|
+
for (const name of fields)
|
|
536
|
+
if (!problemTabs.has(name))
|
|
537
|
+
problemTabs.set(name, open);
|
|
341
538
|
list.append(button);
|
|
342
|
-
return { tab, button, panel };
|
|
539
|
+
return { tab, button, panel, own, build, fields };
|
|
343
540
|
});
|
|
344
541
|
list.addEventListener('keydown', (event) => {
|
|
345
542
|
const shown = parts.filter((p) => !p.button.hidden);
|
|
@@ -353,33 +550,76 @@ function mountViewer(host, options) {
|
|
|
353
550
|
show(next.tab.id);
|
|
354
551
|
next.button.focus();
|
|
355
552
|
});
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
553
|
+
// Too long for its room, the strip scrolls; the tab chosen, however, comes into sight in it.
|
|
554
|
+
const strip = (0, tab_strip_1.tabStrip)(list, el);
|
|
555
|
+
cleanups.push(strip.destroy);
|
|
556
|
+
box.append(strip.element, ...parts.map((p) => p.panel));
|
|
557
|
+
let shownKey = '';
|
|
558
|
+
let revealed = '';
|
|
559
|
+
watch((state) => {
|
|
560
|
+
const tabsHidden = form.node(node.id).invisible;
|
|
561
|
+
(0, dom_1.setHidden)(box, tabsHidden);
|
|
359
562
|
const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
|
|
360
563
|
const keep = picked !== null && shown.some((p) => p.tab.id === picked);
|
|
361
564
|
if (keep)
|
|
362
565
|
active = picked;
|
|
363
566
|
else if (shown.length)
|
|
364
567
|
active = shown[0].tab.id;
|
|
568
|
+
const errors = form.getState().errors;
|
|
365
569
|
for (const p of parts) {
|
|
366
570
|
const selected = p.tab.id === active;
|
|
367
|
-
p.button
|
|
368
|
-
p.button
|
|
369
|
-
p.button.tabIndex
|
|
370
|
-
|
|
571
|
+
(0, dom_1.setHidden)(p.button, !shown.includes(p));
|
|
572
|
+
(0, dom_1.setAttr)(p.button, 'aria-selected', String(selected));
|
|
573
|
+
if (p.button.tabIndex !== (selected ? 0 : -1))
|
|
574
|
+
p.button.tabIndex = selected ? 0 : -1;
|
|
575
|
+
(0, dom_1.setAttr)(p.button, 'data-problem', p.fields.some((name) => errors[name]) ? '' : null);
|
|
576
|
+
(0, dom_1.setHidden)(p.panel, !selected);
|
|
577
|
+
// The tab shown: drawn if it is new, and brought up to date — it was left alone while out of sight.
|
|
578
|
+
if (selected && !tabsHidden) {
|
|
579
|
+
p.build();
|
|
580
|
+
for (const update of p.own)
|
|
581
|
+
update(state);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// Measured only when it can have changed: the strip's tabs, or the one chosen.
|
|
585
|
+
const key = shown.map((p) => p.tab.id).join(' ');
|
|
586
|
+
if (key !== shownKey) {
|
|
587
|
+
shownKey = key;
|
|
588
|
+
strip.measure();
|
|
589
|
+
}
|
|
590
|
+
if (active !== revealed && list.clientWidth) {
|
|
591
|
+
revealed = active;
|
|
592
|
+
const chosen = parts.find((p) => p.tab.id === active);
|
|
593
|
+
if (chosen)
|
|
594
|
+
strip.reveal(chosen.button);
|
|
371
595
|
}
|
|
372
596
|
});
|
|
373
597
|
return box;
|
|
374
598
|
}
|
|
599
|
+
/** The fields a tab holds, in its sections and tabs inside it; a saved form placed in it keeps its own. */
|
|
600
|
+
function fieldsIn(children) {
|
|
601
|
+
return children.flatMap((child) => child.type === 'field' ? [child.field]
|
|
602
|
+
: child.type === 'section' ? fieldsIn(child.children)
|
|
603
|
+
: child.type === 'tabs' ? child.children.flatMap((tab) => fieldsIn(tab.children))
|
|
604
|
+
: []);
|
|
605
|
+
}
|
|
375
606
|
function item(node, place) {
|
|
607
|
+
return hiddenOn(node, partItem(node, place));
|
|
608
|
+
}
|
|
609
|
+
/** A part hidden at some widths of the form (`hideOn`), as a phone's: the stylesheet hides it there. */
|
|
610
|
+
function hiddenOn(node, element) {
|
|
611
|
+
if (node.hideOn?.length)
|
|
612
|
+
element.dataset['hideOn'] = node.hideOn.join(' ');
|
|
613
|
+
return element;
|
|
614
|
+
}
|
|
615
|
+
function partItem(node, place) {
|
|
376
616
|
switch (node.type) {
|
|
377
617
|
case 'field':
|
|
378
618
|
return fieldItem(node, place.labels);
|
|
379
619
|
case 'button':
|
|
380
620
|
return buttonItem(node);
|
|
381
621
|
case 'text':
|
|
382
|
-
return textItem(node);
|
|
622
|
+
return textItem(node, place.inline);
|
|
383
623
|
case 'slot':
|
|
384
624
|
return slotItem(node);
|
|
385
625
|
case 'section':
|
|
@@ -526,9 +766,12 @@ function mountViewer(host, options) {
|
|
|
526
766
|
else
|
|
527
767
|
locked = false;
|
|
528
768
|
root.toggleAttribute('data-readonly', locked);
|
|
769
|
+
// Conditions read `editing`: parts for editing only, or reading only, follow.
|
|
770
|
+
form.setEditing(!locked);
|
|
529
771
|
render(form.getState());
|
|
530
772
|
});
|
|
531
773
|
root.toggleAttribute('data-readonly', locked);
|
|
774
|
+
form.setEditing(!locked);
|
|
532
775
|
updaters.push(() => {
|
|
533
776
|
if (!editSwitch)
|
|
534
777
|
return;
|
|
@@ -626,7 +869,15 @@ function mountViewer(host, options) {
|
|
|
626
869
|
draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
|
|
627
870
|
});
|
|
628
871
|
function focusFirstProblem() {
|
|
629
|
-
|
|
872
|
+
// A problem in a tab out of sight, or not drawn yet: its tab shown first, the first such in the page's order.
|
|
873
|
+
const seen = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]'));
|
|
874
|
+
if (!seen) {
|
|
875
|
+
const errors = form.getState().errors;
|
|
876
|
+
const first = [...problemTabs.keys()].find((name) => errors[name]);
|
|
877
|
+
if (first)
|
|
878
|
+
problemTabs.get(first)?.();
|
|
879
|
+
}
|
|
880
|
+
const invalid = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]')) ?? null;
|
|
630
881
|
if (!invalid)
|
|
631
882
|
return;
|
|
632
883
|
for (let folded = invalid.closest('.fd-section-folded'); folded; folded = folded.parentElement?.closest('.fd-section-folded') ?? null) {
|
|
@@ -661,6 +912,52 @@ function mountViewer(host, options) {
|
|
|
661
912
|
content.hidden = false;
|
|
662
913
|
done.hidden = true;
|
|
663
914
|
});
|
|
915
|
+
/** 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. */
|
|
916
|
+
function recordName() {
|
|
917
|
+
const state = form.getState();
|
|
918
|
+
if (state.recordId == null)
|
|
919
|
+
return '';
|
|
920
|
+
const field = page.layout.type === 'sheet' ? page.layout.title?.field : undefined;
|
|
921
|
+
const name = field ? (0, widgets_1.displayValue)(page.fields[field], state.values[field], state.values, locale) : '';
|
|
922
|
+
return name || page.title || '';
|
|
923
|
+
}
|
|
924
|
+
/** The bar over a record — its breadcrumbs, its gear menu, its pager — or null when it would hold nothing. */
|
|
925
|
+
function topBar(toolbar) {
|
|
926
|
+
if (page.data.kind !== 'record')
|
|
927
|
+
return null;
|
|
928
|
+
const bar = (0, record_bar_1.recordBar)({
|
|
929
|
+
doc,
|
|
930
|
+
el,
|
|
931
|
+
form,
|
|
932
|
+
toolbar,
|
|
933
|
+
records: options.records,
|
|
934
|
+
breadcrumbs: options.breadcrumbs,
|
|
935
|
+
labels,
|
|
936
|
+
icons: options.icons,
|
|
937
|
+
uid,
|
|
938
|
+
fill,
|
|
939
|
+
withIcon,
|
|
940
|
+
press,
|
|
941
|
+
name: recordName,
|
|
942
|
+
render: renderNow,
|
|
943
|
+
async leave() {
|
|
944
|
+
if (!form.getState().dirty.length)
|
|
945
|
+
return true;
|
|
946
|
+
const saved = await form.save();
|
|
947
|
+
if (!saved) {
|
|
948
|
+
if (!form.getState().saveProblem)
|
|
949
|
+
announce(checkText(form.getState()));
|
|
950
|
+
focusFirstProblem();
|
|
951
|
+
}
|
|
952
|
+
return saved;
|
|
953
|
+
},
|
|
954
|
+
});
|
|
955
|
+
if (!bar)
|
|
956
|
+
return null;
|
|
957
|
+
updaters.push(bar.update);
|
|
958
|
+
cleanups.push(bar.destroy);
|
|
959
|
+
return bar.element;
|
|
960
|
+
}
|
|
664
961
|
function pageHead() {
|
|
665
962
|
if (!page.title && !page.description)
|
|
666
963
|
return null;
|
|
@@ -672,8 +969,20 @@ function mountViewer(host, options) {
|
|
|
672
969
|
return head;
|
|
673
970
|
}
|
|
674
971
|
// ---- the four page layouts ---------------------------------------------------
|
|
972
|
+
/**
|
|
973
|
+
* A page's own buttons at its foot, as a wizard's, in place of its Save and
|
|
974
|
+
* Discard; null when it has none, or when a dialog round it draws them.
|
|
975
|
+
*/
|
|
976
|
+
function pageFooter(footer) {
|
|
977
|
+
if (!footer?.length || options.showActions === false)
|
|
978
|
+
return null;
|
|
979
|
+
return el('div', { class: 'fd-actions fd-actions-end fd-page-footer' }, status, ...footer.map(buttonItem));
|
|
980
|
+
}
|
|
675
981
|
function sectionsLayout(node) {
|
|
676
982
|
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
|
|
983
|
+
const own = pageFooter(node.footer);
|
|
984
|
+
if (own)
|
|
985
|
+
return el('div', {}, box, own);
|
|
677
986
|
const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
|
|
678
987
|
const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
|
|
679
988
|
if (page.data.kind === 'record') {
|
|
@@ -792,7 +1101,8 @@ function mountViewer(host, options) {
|
|
|
792
1101
|
// Save and Discard lead the header bar, or close the sheet when the page puts them at its foot.
|
|
793
1102
|
const atFoot = page.actionsPosition === 'bottom';
|
|
794
1103
|
const foot = el('div', { class: 'fd-actions fd-actions-end fd-sheet-foot' });
|
|
795
|
-
|
|
1104
|
+
const own = pageFooter(node.footer);
|
|
1105
|
+
if (options.showActions === false || own)
|
|
796
1106
|
actions.append(...(node.buttons ?? []).map(buttonItem));
|
|
797
1107
|
else if (atFoot) {
|
|
798
1108
|
actions.append(...(node.buttons ?? []).map(buttonItem));
|
|
@@ -813,31 +1123,89 @@ function mountViewer(host, options) {
|
|
|
813
1123
|
if (node.statusbar && !underTitle)
|
|
814
1124
|
header.append(statusbar(node.statusbar));
|
|
815
1125
|
const card = el('div', { class: 'fd-card' });
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1126
|
+
const ribbons = [...(node.ribbon ? [node.ribbon] : []), ...(node.ribbons ?? [])];
|
|
1127
|
+
if (ribbons.length) {
|
|
1128
|
+
// The ribbons are clipped to the card's corner by their own frame, so the card itself never clips: lists open past it.
|
|
1129
|
+
const frame = el('div', { class: 'fd-ribbon-frame' });
|
|
1130
|
+
const drawn = ribbons.map((ribbon) => {
|
|
1131
|
+
const element = el('div', { class: `fd-ribbon fd-tone-${ribbon.tone ?? 'muted'}`, 'data-node': ribbon.id, title: ribbon.tooltip }, ribbon.label);
|
|
1132
|
+
frame.append(element);
|
|
1133
|
+
return { ribbon, element };
|
|
1134
|
+
});
|
|
1135
|
+
// One corner, one ribbon: the first whose condition holds; its words from a field when the field holds any.
|
|
1136
|
+
updaters.push((state) => {
|
|
1137
|
+
let shown = false;
|
|
1138
|
+
for (const { ribbon, element } of drawn) {
|
|
1139
|
+
const show = !shown && !form.node(ribbon.id).invisible;
|
|
1140
|
+
shown ||= show;
|
|
1141
|
+
(0, dom_1.setHidden)(element, !show);
|
|
1142
|
+
if (!show)
|
|
1143
|
+
continue;
|
|
1144
|
+
const from = ribbon.labelField ? (0, widgets_1.displayValue)(page.fields[ribbon.labelField], state.values[ribbon.labelField], state.values, locale) : '';
|
|
1145
|
+
(0, dom_1.setText)(element, from || ribbon.label);
|
|
1146
|
+
}
|
|
1147
|
+
});
|
|
1148
|
+
card.append(frame);
|
|
821
1149
|
}
|
|
822
1150
|
if (node.statButtons?.length) {
|
|
823
1151
|
const stats = el('div', { class: 'fd-stats' });
|
|
1152
|
+
/** A field's value as the field shows it: money with its currency, a date, a count grouped. */
|
|
1153
|
+
const shownValue = (name, values) => name && page.fields[name] ? (0, widgets_1.displayValue)(page.fields[name], values[name], values, locale) : '';
|
|
824
1154
|
for (const stat of node.statButtons) {
|
|
825
1155
|
const value = el('span', { class: 'fd-stat-value' });
|
|
1156
|
+
const label = el('span', { class: 'fd-stat-label' }, stat.label);
|
|
1157
|
+
// A second value with words of its own: the two one over the other, each after its words.
|
|
1158
|
+
const second = stat.secondField && stat.secondLabel !== undefined ? { value: el('span', { class: 'fd-stat-value' }), label: el('span', { class: 'fd-stat-label' }, stat.secondLabel) } : null;
|
|
826
1159
|
const icon = (0, widgets_1.drawIcon)(doc, stat.icon, options.icons);
|
|
827
|
-
const words =
|
|
828
|
-
|
|
1160
|
+
const words = second
|
|
1161
|
+
? 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))
|
|
1162
|
+
: el('span', { class: 'fd-stat-words' }, value, label);
|
|
1163
|
+
// Its help, as Flectra's help=: its tooltip, and its description for a screen reader — never its name.
|
|
1164
|
+
const helpId = stat.help ? uid(`${stat.id}-help`) : undefined;
|
|
1165
|
+
const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id, title: stat.help || undefined, 'aria-describedby': helpId }, ...(icon ? [icon, words] : [words]));
|
|
1166
|
+
if (helpId)
|
|
1167
|
+
stats.append(el('span', { class: 'fd-sr-only', id: helpId }, stat.help));
|
|
829
1168
|
button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
|
|
830
1169
|
updaters.push((state) => {
|
|
831
1170
|
button.hidden = form.node(stat.id).invisible;
|
|
832
|
-
const
|
|
833
|
-
|
|
1171
|
+
const values = state.values;
|
|
1172
|
+
const unit = shownValue(stat.unitField, values) || stat.unit || '';
|
|
1173
|
+
const first = shownValue(stat.field, values);
|
|
1174
|
+
const other = shownValue(stat.secondField, values);
|
|
1175
|
+
if (second) {
|
|
1176
|
+
(0, dom_1.setText)(value, first);
|
|
1177
|
+
(0, dom_1.setText)(second.value, [other, unit].filter(Boolean).join(' '));
|
|
1178
|
+
}
|
|
1179
|
+
else
|
|
1180
|
+
(0, dom_1.setText)(value, [[first, other].filter(Boolean).join(' / '), first || other ? unit : ''].filter(Boolean).join(' '));
|
|
1181
|
+
(0, dom_1.setText)(label, shownValue(stat.labelField, values) || stat.label);
|
|
834
1182
|
});
|
|
835
1183
|
stats.append(button);
|
|
836
1184
|
}
|
|
1185
|
+
// No empty row while every button is hidden, as with the badges.
|
|
1186
|
+
updaters.push(() => {
|
|
1187
|
+
stats.hidden = [...stats.querySelectorAll(':scope > .fd-stat')].every((stat) => stat.hidden);
|
|
1188
|
+
});
|
|
837
1189
|
card.append(stats);
|
|
838
1190
|
}
|
|
839
1191
|
for (const alert of node.alerts ?? []) {
|
|
840
|
-
|
|
1192
|
+
// Its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled.
|
|
1193
|
+
const words = (0, value_words_1.valueWords)(doc, page.fields, alert.message, locale);
|
|
1194
|
+
const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
|
|
1195
|
+
let fromField = false;
|
|
1196
|
+
const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
|
|
1197
|
+
if (alert.buttons?.length)
|
|
1198
|
+
box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
|
|
1199
|
+
updaters.push((state) => {
|
|
1200
|
+
words.update(state.values);
|
|
1201
|
+
const from = alert.messageField ? state.values[alert.messageField] : null;
|
|
1202
|
+
const said = typeof from === 'string' ? from.trim() : '';
|
|
1203
|
+
if (said)
|
|
1204
|
+
(0, dom_1.setText)(message, said);
|
|
1205
|
+
else if (fromField)
|
|
1206
|
+
message.replaceChildren(...words.nodes);
|
|
1207
|
+
fromField = !!said;
|
|
1208
|
+
});
|
|
841
1209
|
if (alert.dismissible) {
|
|
842
1210
|
// Closed, it stays closed while the page is open, whatever its condition does.
|
|
843
1211
|
let dismissed = false;
|
|
@@ -869,9 +1237,26 @@ function mountViewer(host, options) {
|
|
|
869
1237
|
}
|
|
870
1238
|
if (node.title) {
|
|
871
1239
|
const title = el('div', { class: 'fd-title' });
|
|
1240
|
+
// Words over the title, as Flectra's "Product Name" over its h1, naming its box.
|
|
1241
|
+
if (node.title.label)
|
|
1242
|
+
title.append(el('label', { class: 'fd-title-label', for: uid('#title') }, node.title.label));
|
|
872
1243
|
if (node.title.above?.length)
|
|
873
1244
|
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
|
|
874
|
-
|
|
1245
|
+
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 } : {}) });
|
|
1246
|
+
// Fields on the title's line, before and after it: a priority star, a state's dot (Flectra's <h1>).
|
|
1247
|
+
const before = node.title.before ?? [];
|
|
1248
|
+
const after = node.title.after ?? [];
|
|
1249
|
+
if (before.length || after.length) {
|
|
1250
|
+
name.classList.add('fd-title-name');
|
|
1251
|
+
const side = (part) => {
|
|
1252
|
+
const item = fieldItem(part);
|
|
1253
|
+
item.classList.add('fd-title-side');
|
|
1254
|
+
return item;
|
|
1255
|
+
};
|
|
1256
|
+
title.append(el('div', { class: 'fd-title-line' }, ...before.map(side), name, ...after.map(side)));
|
|
1257
|
+
}
|
|
1258
|
+
else
|
|
1259
|
+
title.append(name);
|
|
875
1260
|
if (node.title.subtitleField)
|
|
876
1261
|
title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
|
|
877
1262
|
if (node.title.below?.length)
|
|
@@ -887,6 +1272,8 @@ function mountViewer(host, options) {
|
|
|
887
1272
|
if (node.statusbar && underTitle)
|
|
888
1273
|
card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
|
|
889
1274
|
card.append(grid(node.children, 1, top(false)));
|
|
1275
|
+
if (own)
|
|
1276
|
+
card.append(own);
|
|
890
1277
|
if (atFoot && options.showActions !== false) {
|
|
891
1278
|
card.append(foot);
|
|
892
1279
|
updaters.push((state) => {
|
|
@@ -894,11 +1281,22 @@ function mountViewer(host, options) {
|
|
|
894
1281
|
});
|
|
895
1282
|
}
|
|
896
1283
|
const layout = el('div', { class: 'fd-sheet-layout' }, card);
|
|
1284
|
+
if (node.attachmentPreview) {
|
|
1285
|
+
// Beside the sheet on a wide form, the side panel then under the sheet; under the sheet on a narrow one.
|
|
1286
|
+
const preview = (0, attachment_preview_1.attachmentPreview)({ doc, el, form, page, node: node.attachmentPreview, dataSource: options.dataSource, labels, fill });
|
|
1287
|
+
layout.classList.add('fd-has-preview');
|
|
1288
|
+
layout.append(preview.element);
|
|
1289
|
+
updaters.push(preview.update);
|
|
1290
|
+
cleanups.push(preview.destroy);
|
|
1291
|
+
}
|
|
897
1292
|
if (node.sidePanel) {
|
|
898
1293
|
layout.classList.add('fd-has-side');
|
|
1294
|
+
if (node.sidePanelBeside)
|
|
1295
|
+
layout.setAttribute('data-side-beside', node.sidePanelBeside);
|
|
899
1296
|
layout.append(el('aside', { class: 'fd-side' }, slotItem(node.sidePanel)));
|
|
900
1297
|
}
|
|
901
|
-
|
|
1298
|
+
const bar = topBar(node.toolbar);
|
|
1299
|
+
pageBox.append(...(bar ? [bar] : []), header, layout);
|
|
902
1300
|
return pageBox;
|
|
903
1301
|
}
|
|
904
1302
|
/** The sheet header's statusbar: the same widget a form can place anywhere. */
|
|
@@ -907,9 +1305,18 @@ function mountViewer(host, options) {
|
|
|
907
1305
|
const barOptions = { clickable: config.clickable === true };
|
|
908
1306
|
if (config.visibleStates)
|
|
909
1307
|
barOptions['visibleStates'] = config.visibleStates;
|
|
1308
|
+
for (const key of ['durationsField', 'fold', 'saves'])
|
|
1309
|
+
if (config[key] !== undefined)
|
|
1310
|
+
barOptions[key] = config[key];
|
|
910
1311
|
const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
|
|
911
1312
|
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);
|
|
912
|
-
|
|
1313
|
+
if (widget.destroy)
|
|
1314
|
+
cleanups.push(() => widget.destroy?.());
|
|
1315
|
+
updaters.push((state) => {
|
|
1316
|
+
// Hidden while its own condition holds, or from people outside its roles.
|
|
1317
|
+
(0, dom_1.setHidden)(widget.element, form.node('#statusbar').invisible);
|
|
1318
|
+
widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false });
|
|
1319
|
+
});
|
|
913
1320
|
return widget.element;
|
|
914
1321
|
}
|
|
915
1322
|
let wizardForward = null;
|
|
@@ -938,6 +1345,7 @@ function mountViewer(host, options) {
|
|
|
938
1345
|
icons: options.icons,
|
|
939
1346
|
preferences,
|
|
940
1347
|
onOpenRecord: options.onOpenRecord,
|
|
1348
|
+
...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
|
|
941
1349
|
});
|
|
942
1350
|
cleanups.push(list.destroy);
|
|
943
1351
|
body = list.element;
|
|
@@ -945,7 +1353,9 @@ function mountViewer(host, options) {
|
|
|
945
1353
|
else
|
|
946
1354
|
body = sectionsLayout(layout);
|
|
947
1355
|
const head = layout.type === 'sheet' ? null : pageHead();
|
|
948
|
-
|
|
1356
|
+
// A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
|
|
1357
|
+
const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
|
|
1358
|
+
content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
|
|
949
1359
|
root.append(content, done, announcer);
|
|
950
1360
|
// The status leaves its place beside Save when the page wants it in a corner or across the top.
|
|
951
1361
|
if (options.saveStatus === 'toast') {
|
|
@@ -956,6 +1366,18 @@ function mountViewer(host, options) {
|
|
|
956
1366
|
status.classList.add('fd-status-bar');
|
|
957
1367
|
content.prepend(status);
|
|
958
1368
|
}
|
|
1369
|
+
// A value too long for its box ends in an ellipsis; the pointer over it shows all of it.
|
|
1370
|
+
// Set as the pointer comes, when the box's width is known, and never over a title of the box's own.
|
|
1371
|
+
root.addEventListener('mouseover', (event) => {
|
|
1372
|
+
const box = event.target;
|
|
1373
|
+
if (box.tagName !== 'INPUT' || !box.classList.contains('fd-input'))
|
|
1374
|
+
return;
|
|
1375
|
+
if (box.title && !box.hasAttribute('data-cut-title'))
|
|
1376
|
+
return;
|
|
1377
|
+
const cut = box.scrollWidth > box.clientWidth && box.value !== '';
|
|
1378
|
+
(0, dom_1.setAttr)(box, 'title', cut ? box.value : null);
|
|
1379
|
+
(0, dom_1.setAttr)(box, 'data-cut-title', cut ? '' : null);
|
|
1380
|
+
});
|
|
959
1381
|
root.addEventListener('submit', (event) => {
|
|
960
1382
|
event.preventDefault();
|
|
961
1383
|
if (layout.type === 'list')
|
|
@@ -1020,6 +1442,46 @@ function mountViewer(host, options) {
|
|
|
1020
1442
|
event.preventDefault();
|
|
1021
1443
|
nextField(from)?.focus();
|
|
1022
1444
|
});
|
|
1445
|
+
// ---- a button's key, with Alt -------------------------------------------------
|
|
1446
|
+
/**
|
|
1447
|
+
* Whether a key pressed with Alt is this form's: pressed in it (not in a
|
|
1448
|
+
* form inside it, such as a dialog's), or — pressed outside any form — this
|
|
1449
|
+
* is the first form on the page, or in the question or dialog on top of it.
|
|
1450
|
+
*/
|
|
1451
|
+
function hotkeysHere(target) {
|
|
1452
|
+
const inner = target instanceof Element ? target.closest('.fd-form') : null;
|
|
1453
|
+
if (inner)
|
|
1454
|
+
return inner === root && !root.querySelector(':scope > .fd-dialog-backdrop');
|
|
1455
|
+
const modals = [...doc.querySelectorAll('[aria-modal="true"]')].filter((modal) => !modal.closest('[hidden]'));
|
|
1456
|
+
const top = modals[modals.length - 1];
|
|
1457
|
+
return (top ?? doc).querySelector('.fd-form') === root;
|
|
1458
|
+
}
|
|
1459
|
+
const onHotkey = (event) => {
|
|
1460
|
+
if (event.key === 'Alt') {
|
|
1461
|
+
if (hotkeysHere(event.target))
|
|
1462
|
+
root.setAttribute('data-hotkeys', '');
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
const key = (0, hotkeys_1.hotkeyOf)(event);
|
|
1466
|
+
if (!key || !hotkeysHere(event.target))
|
|
1467
|
+
return;
|
|
1468
|
+
const button = [...root.querySelectorAll(`button[data-hotkey="${key}"]`)].find((b) => b.closest('.fd-form') === root && !b.closest('[hidden]') && !b.disabled && !b.hasAttribute('aria-busy'));
|
|
1469
|
+
if (!button)
|
|
1470
|
+
return;
|
|
1471
|
+
event.preventDefault();
|
|
1472
|
+
root.removeAttribute('data-hotkeys');
|
|
1473
|
+
button.click();
|
|
1474
|
+
};
|
|
1475
|
+
const offHotkeys = () => root.removeAttribute('data-hotkeys');
|
|
1476
|
+
const altUp = (event) => event.key === 'Alt' && offHotkeys();
|
|
1477
|
+
doc.addEventListener('keydown', onHotkey);
|
|
1478
|
+
doc.addEventListener('keyup', altUp);
|
|
1479
|
+
doc.defaultView?.addEventListener('blur', offHotkeys);
|
|
1480
|
+
cleanups.push(() => {
|
|
1481
|
+
doc.removeEventListener('keydown', onHotkey);
|
|
1482
|
+
doc.removeEventListener('keyup', altUp);
|
|
1483
|
+
doc.defaultView?.removeEventListener('blur', offHotkeys);
|
|
1484
|
+
});
|
|
1023
1485
|
// ---- confirmation dialog -----------------------------------------------------
|
|
1024
1486
|
/** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
|
|
1025
1487
|
function dialogConfirm(message, withCancel = true) {
|
|
@@ -1069,6 +1531,24 @@ function mountViewer(host, options) {
|
|
|
1069
1531
|
}));
|
|
1070
1532
|
render(form.getState());
|
|
1071
1533
|
mounted = true;
|
|
1534
|
+
// As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
|
|
1535
|
+
// only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
|
|
1536
|
+
let opened = false;
|
|
1537
|
+
if (focusers.length) {
|
|
1538
|
+
cleanups.push(form.on('open', () => {
|
|
1539
|
+
const first = !opened;
|
|
1540
|
+
opened = true;
|
|
1541
|
+
queueMicrotask(() => {
|
|
1542
|
+
if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
|
|
1543
|
+
return;
|
|
1544
|
+
const active = doc.activeElement;
|
|
1545
|
+
const free = !active || active === doc.body || active === doc.documentElement;
|
|
1546
|
+
if (!free && !(first && root.contains(active)))
|
|
1547
|
+
return;
|
|
1548
|
+
focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
|
|
1549
|
+
});
|
|
1550
|
+
}));
|
|
1551
|
+
}
|
|
1072
1552
|
if (form.getState().status === 'idle')
|
|
1073
1553
|
void form.load();
|
|
1074
1554
|
return {
|
|
@@ -1077,6 +1557,17 @@ function mountViewer(host, options) {
|
|
|
1077
1557
|
setSkin(skin) {
|
|
1078
1558
|
root.setAttribute('data-fd-skin', skin);
|
|
1079
1559
|
},
|
|
1560
|
+
setTheme(next) {
|
|
1561
|
+
if (next)
|
|
1562
|
+
root.setAttribute('data-fd-theme', next);
|
|
1563
|
+
else
|
|
1564
|
+
root.removeAttribute('data-fd-theme');
|
|
1565
|
+
root.setAttribute('data-fd-skin', (0, look_1.skinFor)(options.skin, next ?? undefined));
|
|
1566
|
+
},
|
|
1567
|
+
setTokens(next) {
|
|
1568
|
+
options.tokens = next ?? undefined;
|
|
1569
|
+
(0, look_1.wearTokens)(root, next);
|
|
1570
|
+
},
|
|
1080
1571
|
async save() {
|
|
1081
1572
|
const saved = await form.save();
|
|
1082
1573
|
if (!saved)
|
|
@@ -1092,6 +1583,7 @@ function mountViewer(host, options) {
|
|
|
1092
1583
|
setReadonly(readonly) {
|
|
1093
1584
|
locked = readonly;
|
|
1094
1585
|
root.toggleAttribute('data-readonly', locked);
|
|
1586
|
+
form.setEditing(!locked);
|
|
1095
1587
|
render(form.getState());
|
|
1096
1588
|
},
|
|
1097
1589
|
isReadonly: () => locked,
|
|
@@ -1117,4 +1609,36 @@ function mountViewer(host, options) {
|
|
|
1117
1609
|
const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
|
|
1118
1610
|
/** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
|
|
1119
1611
|
const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
|
|
1612
|
+
/**
|
|
1613
|
+
* A label's words with a lone mark at their end ("Completion %", "Come
|
|
1614
|
+
* again ?") held to the word before it, so the mark never wraps onto a line
|
|
1615
|
+
* of its own.
|
|
1616
|
+
*/
|
|
1617
|
+
function keepMarkWithWord(text) {
|
|
1618
|
+
return text.replace(/\s+([^\p{L}\p{N}\s]{1,3})$/u, '\u00a0$1');
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* A one-line row's parts in runs that wrap as one: words before a field
|
|
1622
|
+
* ("Expected:", "to") go with it, and words after it that no field follows
|
|
1623
|
+
* ("attempts") too, as Flectra keeps them in one span; any other part runs
|
|
1624
|
+
* alone.
|
|
1625
|
+
*/
|
|
1626
|
+
function onelineRuns(children) {
|
|
1627
|
+
const runs = [];
|
|
1628
|
+
let i = 0;
|
|
1629
|
+
while (i < children.length) {
|
|
1630
|
+
const run = [children[i]];
|
|
1631
|
+
let field = children[i].type === 'field';
|
|
1632
|
+
if (!field && children[i].type === 'text' && children[i + 1]?.type === 'field') {
|
|
1633
|
+
run.push(children[++i]);
|
|
1634
|
+
field = true;
|
|
1635
|
+
}
|
|
1636
|
+
i++;
|
|
1637
|
+
if (field)
|
|
1638
|
+
while (children[i]?.type === 'text' && children[i + 1]?.type !== 'field')
|
|
1639
|
+
run.push(children[i++]);
|
|
1640
|
+
runs.push(run);
|
|
1641
|
+
}
|
|
1642
|
+
return runs;
|
|
1643
|
+
}
|
|
1120
1644
|
//# sourceMappingURL=viewer.js.map
|