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