@fieldia/viewer 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundle/fieldia.ar.js +2 -2
- package/bundle/fieldia.ar.js.map +2 -2
- package/bundle/fieldia.de.js +2 -2
- package/bundle/fieldia.de.js.map +2 -2
- package/bundle/fieldia.fr.js +2 -2
- package/bundle/fieldia.fr.js.map +2 -2
- package/bundle/fieldia.js +6 -4
- package/bundle/fieldia.js.map +4 -4
- package/package.json +3 -3
- package/src/lib/attachment-preview.d.mts +27 -0
- package/src/lib/attachment-preview.d.ts +27 -0
- package/src/lib/attachment-preview.js +89 -0
- package/src/lib/attachment-preview.js.map +1 -0
- package/src/lib/attachment-preview.mjs +86 -0
- package/src/lib/attachment-preview.mjs.map +1 -0
- package/src/lib/dialog.d.mts +2 -0
- package/src/lib/dialog.d.ts +2 -0
- package/src/lib/dialog.js +65 -4
- package/src/lib/dialog.js.map +1 -1
- package/src/lib/dialog.mjs +64 -4
- 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/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 +22 -8
- package/src/lib/related.js.map +1 -1
- package/src/lib/related.mjs +22 -8
- 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 +22 -1
- package/src/lib/viewer.d.ts +22 -1
- package/src/lib/viewer.js +488 -46
- package/src/lib/viewer.js.map +1 -1
- package/src/lib/viewer.mjs +489 -47
- package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
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
5
|
import { applyLook } from './look.mjs';
|
|
@@ -7,6 +7,12 @@ 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';
|
|
@@ -19,7 +25,8 @@ let mounts = 0;
|
|
|
19
25
|
export function mountViewer(host, options) {
|
|
20
26
|
const doc = host.ownerDocument;
|
|
21
27
|
// The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
|
|
22
|
-
|
|
28
|
+
// A line's own form reads the record the line is on as parent.
|
|
29
|
+
const checked = checkPage(options.page, options.parent ? { parent: options.parent.fields } : {});
|
|
23
30
|
if (!checked.ok) {
|
|
24
31
|
throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
|
|
25
32
|
}
|
|
@@ -34,7 +41,7 @@ export function mountViewer(host, options) {
|
|
|
34
41
|
};
|
|
35
42
|
const locale = ownLocale(tag);
|
|
36
43
|
const preferences = options.preferences ?? browserPreferences();
|
|
37
|
-
const dialogs = pageDialogs(options);
|
|
44
|
+
const dialogs = pageDialogs(options, (message, tone) => actionHost.say(message, tone));
|
|
38
45
|
const ownsForm = !options.form;
|
|
39
46
|
const labels = { ...VIEWER_LABELS[locale], ...options.labels };
|
|
40
47
|
/**
|
|
@@ -43,7 +50,17 @@ export function mountViewer(host, options) {
|
|
|
43
50
|
* made elsewhere (`form`) keeps the host it was made with.
|
|
44
51
|
*/
|
|
45
52
|
const actionHost = {
|
|
46
|
-
open: (request) => openPage({
|
|
53
|
+
open: (request) => openPage({
|
|
54
|
+
options,
|
|
55
|
+
place: host,
|
|
56
|
+
root,
|
|
57
|
+
title: (other) => shownAs(other).title || other.id,
|
|
58
|
+
back: labels.back,
|
|
59
|
+
missing: (id) => fill(labels.pageMissing, { page: id }),
|
|
60
|
+
say: (message, tone) => actionHost.say(message, tone),
|
|
61
|
+
post: (message) => form.post(message),
|
|
62
|
+
name: () => recordName() || labels.newRecord,
|
|
63
|
+
}, request),
|
|
47
64
|
say: (message, tone) => say(message, tone),
|
|
48
65
|
ask: (message) => confirm(message),
|
|
49
66
|
show: (target) => {
|
|
@@ -51,6 +68,8 @@ export function mountViewer(host, options) {
|
|
|
51
68
|
if (!show?.())
|
|
52
69
|
throw new Error(`No tab "${target}" can be shown here`);
|
|
53
70
|
},
|
|
71
|
+
// A new tab opened apart from this page, or this one sent there.
|
|
72
|
+
openUrl: (url, newTab) => (newTab ? doc.defaultView?.open(url, '_blank', 'noopener,noreferrer') : doc.defaultView?.location.assign(url)),
|
|
54
73
|
...options.host,
|
|
55
74
|
};
|
|
56
75
|
const form = options.form ?? createForm({ ...options, page, host: actionHost, messages: options.messages ?? MESSAGES[locale] });
|
|
@@ -58,11 +77,17 @@ export function mountViewer(host, options) {
|
|
|
58
77
|
const dir = options.dir ?? (tag && isRightToLeft(tag) ? 'rtl' : undefined);
|
|
59
78
|
const prefix = `fd${++mounts}`;
|
|
60
79
|
const updaters = [];
|
|
80
|
+
/**
|
|
81
|
+
* Where a part's updater goes as it is drawn: the form's own list, or the list
|
|
82
|
+
* of the tab it is drawn in, which runs only while that tab is shown.
|
|
83
|
+
*/
|
|
84
|
+
let sink = updaters;
|
|
61
85
|
const cleanups = [];
|
|
62
86
|
const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
|
|
63
87
|
const uid = ownUid;
|
|
64
88
|
const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
|
|
65
89
|
installStyles(doc);
|
|
90
|
+
installRecordStyles(doc);
|
|
66
91
|
function el(tag, attrs = {}, ...children) {
|
|
67
92
|
const node = doc.createElement(tag);
|
|
68
93
|
for (const [name, value] of Object.entries(attrs))
|
|
@@ -87,6 +112,8 @@ export function mountViewer(host, options) {
|
|
|
87
112
|
const say = sayer(root, el, labels.dismiss);
|
|
88
113
|
/** The tabs a step can show, by id: each shows its tab, false when it is hidden. */
|
|
89
114
|
const tabs = new Map();
|
|
115
|
+
/** For each field in a tab, how to show its tab: a problem there is shown before the focus goes to it. */
|
|
116
|
+
const problemTabs = new Map();
|
|
90
117
|
/**
|
|
91
118
|
* A press runs its steps one run at a time: the button busy meanwhile, and
|
|
92
119
|
* not pressed again. A check or a save that stops it takes the focus to the
|
|
@@ -111,6 +138,8 @@ export function mountViewer(host, options) {
|
|
|
111
138
|
}
|
|
112
139
|
}
|
|
113
140
|
// ---- the parts -------------------------------------------------------
|
|
141
|
+
/** The fields that take the focus as the record opens, in the order they are drawn. */
|
|
142
|
+
const focusers = [];
|
|
114
143
|
/** Folded sections, and how to open each: a problem inside one has to be seen. */
|
|
115
144
|
const folds = new Map();
|
|
116
145
|
/** Every part drawn again from the state of the page shown. */
|
|
@@ -133,13 +162,13 @@ export function mountViewer(host, options) {
|
|
|
133
162
|
const { form, page } = scope;
|
|
134
163
|
const uid = (id) => ownUid(scope.prefix + id);
|
|
135
164
|
/** 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,
|
|
165
|
+
const watch = (update) => sink.push(scope.root ? update : () => update(form.getState()));
|
|
166
|
+
/** A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none. */
|
|
167
|
+
function fieldItem(node, place) {
|
|
139
168
|
const def = page.fields[node.field];
|
|
140
169
|
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 });
|
|
170
|
+
const labelsAt = labelPlace(node, def.type, place);
|
|
171
|
+
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
172
|
if (node.colspan)
|
|
144
173
|
wrapper.style.setProperty('--fd-span', String(node.colspan));
|
|
145
174
|
const labelText = node.label ?? def.label;
|
|
@@ -151,11 +180,16 @@ export function mountViewer(host, options) {
|
|
|
151
180
|
label.append(mark);
|
|
152
181
|
}
|
|
153
182
|
const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
183
|
+
// 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.
|
|
184
|
+
if (node.focus && scope.root)
|
|
185
|
+
focusers.push({ wrapper, focus: () => widget.focus() });
|
|
154
186
|
// 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
|
-
|
|
187
|
+
const textBox = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
|
|
188
|
+
if (labelsAt === 'hidden' && !node.placeholder)
|
|
189
|
+
textBox?.setAttribute('placeholder', labelText);
|
|
190
|
+
// Words chosen by a condition (placeholderWhen), else what the box showed as it was drawn.
|
|
191
|
+
const drawnPlaceholder = textBox?.getAttribute('placeholder') ?? null;
|
|
192
|
+
const chosenPlaceholder = node.placeholderWhen?.length ? textBox : null;
|
|
159
193
|
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
|
|
160
194
|
// `for` stays: a custom field that puts the id on its own input is
|
|
161
195
|
// labelled natively. Only a wrapper with a role (a radio group, say) may
|
|
@@ -166,12 +200,24 @@ export function mountViewer(host, options) {
|
|
|
166
200
|
label.addEventListener('click', () => widget.focus());
|
|
167
201
|
}
|
|
168
202
|
const helpText = node.help ?? def.help;
|
|
169
|
-
|
|
203
|
+
// Under the field, behind a (?) by its label, or both: the field's own way, else the page's.
|
|
204
|
+
const helpWay = node.helpShown ?? page.look?.helpShown ?? 'below';
|
|
205
|
+
const help = helpText && helpWay !== 'tooltip' ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
|
|
206
|
+
const tip = helpText && helpWay !== 'below' ? helpTip(`${id}-tip`, helpText, fill(labels.helpFor, { label: labelText })) : null;
|
|
207
|
+
// 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.
|
|
208
|
+
if (tip && labelsAt === 'hidden')
|
|
209
|
+
widget.element.after(tip.element);
|
|
210
|
+
else if (tip)
|
|
211
|
+
label.append(tip.element);
|
|
170
212
|
// Not an alert of its own: a refused save is announced once, naming every field to look at.
|
|
171
213
|
const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
|
|
172
214
|
// A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
|
|
173
215
|
const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
|
|
174
216
|
wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
|
|
217
|
+
// Read-only, as words in place of the box, when the page draws them so.
|
|
218
|
+
const asText = page.look?.readonlyShown === 'text' && readsAsText(def, node) ? readText({ document: doc, field: def, node, id, locale, labels: widgetLabels, dialogs }) : null;
|
|
219
|
+
if (asText)
|
|
220
|
+
widget.element.after(asText.element);
|
|
175
221
|
if (widget.destroy)
|
|
176
222
|
cleanups.push(() => widget.destroy?.());
|
|
177
223
|
// An answer rule's warning waits until the person leaves the field: no advice
|
|
@@ -187,10 +233,19 @@ export function mountViewer(host, options) {
|
|
|
187
233
|
// (a deleted line's button), and that redraw must finish first.
|
|
188
234
|
queueMicrotask(() => update(form.getState()));
|
|
189
235
|
});
|
|
236
|
+
const toned = node.tones !== undefined || node.bold !== undefined;
|
|
190
237
|
const update = (state) => {
|
|
191
238
|
const shown = form.node(node.id);
|
|
192
239
|
setHidden(wrapper, shown.invisible);
|
|
193
240
|
wrapper.classList.toggle('fd-required', shown.required);
|
|
241
|
+
// Its value's tone and bold while a condition holds: Flectra's decoration-* on a field.
|
|
242
|
+
if (toned) {
|
|
243
|
+
const look = form.fieldTone(node.id);
|
|
244
|
+
setAttr(wrapper, 'data-tone', look.tone);
|
|
245
|
+
wrapper.classList.toggle('fd-value-bold', look.bold);
|
|
246
|
+
}
|
|
247
|
+
if (chosenPlaceholder)
|
|
248
|
+
setAttr(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? drawnPlaceholder);
|
|
194
249
|
const message = state.errors[node.field];
|
|
195
250
|
setHidden(error, !message);
|
|
196
251
|
setText(error, message ?? '');
|
|
@@ -208,34 +263,80 @@ export function mountViewer(host, options) {
|
|
|
208
263
|
wrapper.classList.toggle('fd-valid', valid);
|
|
209
264
|
mark.toggleAttribute('hidden', !valid);
|
|
210
265
|
}
|
|
266
|
+
const readonly = shown.readonly || locked || scope.locked();
|
|
267
|
+
if (asText) {
|
|
268
|
+
setHidden(widget.element, readonly);
|
|
269
|
+
setHidden(asText.element, !readonly);
|
|
270
|
+
setAttr(wrapper, 'data-read-text', readonly ? '' : null);
|
|
271
|
+
if (readonly)
|
|
272
|
+
asText.update(state.values[node.field], state.values);
|
|
273
|
+
}
|
|
211
274
|
widget.update({
|
|
212
275
|
value: state.values[node.field],
|
|
213
276
|
values: state.values,
|
|
214
|
-
readonly
|
|
277
|
+
readonly,
|
|
215
278
|
required: shown.required,
|
|
216
279
|
invalid: !!message,
|
|
217
|
-
describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
280
|
+
describedBy: [help?.id ?? tip?.bubble.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
218
281
|
});
|
|
219
282
|
};
|
|
220
283
|
watch(update);
|
|
221
284
|
return wrapper;
|
|
222
285
|
}
|
|
286
|
+
/** 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. */
|
|
287
|
+
function helpTip(bubbleId, text, name) {
|
|
288
|
+
const bubble = el('span', { class: 'fd-help-bubble', role: 'tooltip', id: bubbleId, hidden: '' }, text);
|
|
289
|
+
const button = el('button', { type: 'button', class: 'fd-help-tip', 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, '?');
|
|
290
|
+
let kept = false;
|
|
291
|
+
const show = (open) => {
|
|
292
|
+
bubble.hidden = !open;
|
|
293
|
+
button.setAttribute('aria-expanded', String(open));
|
|
294
|
+
};
|
|
295
|
+
button.addEventListener('mouseenter', () => show(true));
|
|
296
|
+
button.addEventListener('mouseleave', () => show(kept));
|
|
297
|
+
button.addEventListener('focus', () => show(true));
|
|
298
|
+
button.addEventListener('blur', () => ((kept = false), show(false)));
|
|
299
|
+
button.addEventListener('click', (event) => {
|
|
300
|
+
event.preventDefault();
|
|
301
|
+
kept = !kept;
|
|
302
|
+
show(kept);
|
|
303
|
+
});
|
|
304
|
+
button.addEventListener('keydown', (event) => {
|
|
305
|
+
if (event.key !== 'Escape' || bubble.hidden)
|
|
306
|
+
return;
|
|
307
|
+
// Escape closes the bubble, not the dialog the form is in.
|
|
308
|
+
event.stopPropagation();
|
|
309
|
+
kept = false;
|
|
310
|
+
show(false);
|
|
311
|
+
});
|
|
312
|
+
return { element: el('span', { class: 'fd-help-tip-wrap' }, button, bubble), bubble };
|
|
313
|
+
}
|
|
223
314
|
function hideWhen(element, id) {
|
|
224
315
|
watch(() => setHidden(element, form.node(id).invisible));
|
|
225
316
|
}
|
|
226
317
|
function buttonItem(node) {
|
|
227
318
|
const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
|
|
319
|
+
if (node.hotkey)
|
|
320
|
+
hotkeyOn(button, node.hotkey, node.label);
|
|
228
321
|
spans(button, node.colspan);
|
|
229
322
|
// Its confirmation, then its steps: the form asks, through the viewer.
|
|
230
323
|
button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
|
|
231
324
|
hideWhen(button, node.id);
|
|
232
|
-
|
|
325
|
+
// A header's button is drawn here too, never through item(): hidden at its widths the same way.
|
|
326
|
+
return hiddenOn(node, button);
|
|
233
327
|
}
|
|
234
|
-
function textItem(node) {
|
|
328
|
+
function textItem(node, inline = false) {
|
|
235
329
|
const style = node.style ?? 'paragraph';
|
|
236
|
-
|
|
330
|
+
// Its words, with the values of the fields they name.
|
|
331
|
+
const words = valueWords(doc, page.fields, node.text, locale);
|
|
332
|
+
const element = inline
|
|
333
|
+
? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...words.nodes)
|
|
334
|
+
: style === 'alert'
|
|
335
|
+
? 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))
|
|
336
|
+
: el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...words.nodes);
|
|
237
337
|
spans(element, node.colspan);
|
|
238
338
|
hideWhen(element, node.id);
|
|
339
|
+
watch((state) => words.update(state.values));
|
|
239
340
|
return element;
|
|
240
341
|
}
|
|
241
342
|
function slotItem(node) {
|
|
@@ -254,6 +355,9 @@ export function mountViewer(host, options) {
|
|
|
254
355
|
const box = el('div', { class: 'fd-grid' });
|
|
255
356
|
if (columns !== null) {
|
|
256
357
|
box.style.setProperty('--fd-columns', String(wideColumns(columns)));
|
|
358
|
+
// Twelfths: eleven gaps between them, which shrink in a narrow column rather than push past it.
|
|
359
|
+
if (wideColumns(columns) === 12)
|
|
360
|
+
box.setAttribute('data-twelfths', '');
|
|
257
361
|
// Counts given for the narrower widths replace the skin's own stacking there.
|
|
258
362
|
if (typeof columns === 'object') {
|
|
259
363
|
for (const width of ['medium', 'narrow']) {
|
|
@@ -268,7 +372,44 @@ export function mountViewer(host, options) {
|
|
|
268
372
|
box.append(...children.map((child) => item(child, place)));
|
|
269
373
|
return box;
|
|
270
374
|
}
|
|
375
|
+
/**
|
|
376
|
+
* A row of twelfths led by a field whose label sits beside it, as Flectra's
|
|
377
|
+
* `<label/><div class="o_row">`: that label goes with the labels round it,
|
|
378
|
+
* and the parts share the room their values have.
|
|
379
|
+
*/
|
|
380
|
+
function labelledRow(node, plan) {
|
|
381
|
+
const [first] = node.children;
|
|
382
|
+
if (!plan.arrangement || plan.at === 'tracks' || wideColumns(node.columns) !== 12 || node.children.length < 2 || first?.type !== 'field')
|
|
383
|
+
return null;
|
|
384
|
+
// One line of them: parts running past twelve twelfths are a column of fields, each with its own label.
|
|
385
|
+
if (node.children.reduce((sum, child) => sum + (child.colspan ?? 1), 0) > 12)
|
|
386
|
+
return null;
|
|
387
|
+
const type = page.fields[first.field]?.type;
|
|
388
|
+
const where = type ? labelPlace(first, type, plan.inner.labels) : 'hidden';
|
|
389
|
+
// The skin's place: beside in the underline skin, above in the outlined one, as the stylesheet says.
|
|
390
|
+
return where === undefined ? 'skin' : where === 'beside' ? 'beside' : null;
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* A line of parts, as Flectra's `<label/><div class="o_row">`: its title as
|
|
394
|
+
* the line's label, where the labels round it sit, and its parts after it,
|
|
395
|
+
* each as wide as it needs, its fields named for a screen reader.
|
|
396
|
+
*/
|
|
397
|
+
function inlineItem(node, place) {
|
|
398
|
+
const line = el('div', { class: 'fd-field fd-oneline', 'data-node': node.id, 'data-style': 'inline', 'data-labels': place.labels });
|
|
399
|
+
spans(line, node.colspan);
|
|
400
|
+
const row = el('div', { class: 'fd-oneline-row', role: 'group' }, ...node.children.map((child) => item(child, { columns: 1, onPage: false, inline: true })));
|
|
401
|
+
if (node.title) {
|
|
402
|
+
const title = el('span', { class: 'fd-label', id: uid(`${node.id}-label`) }, node.title);
|
|
403
|
+
row.setAttribute('aria-labelledby', title.id);
|
|
404
|
+
line.append(title);
|
|
405
|
+
}
|
|
406
|
+
line.append(row);
|
|
407
|
+
hideWhen(line, node.id);
|
|
408
|
+
return line;
|
|
409
|
+
}
|
|
271
410
|
function sectionItem(node, place) {
|
|
411
|
+
if (node.style === 'inline')
|
|
412
|
+
return inlineItem(node, place);
|
|
272
413
|
const plan = planSection(node, place);
|
|
273
414
|
// An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
|
|
274
415
|
const section = el(plan.arrangement ? 'div' : 'fieldset', {
|
|
@@ -279,6 +420,9 @@ export function mountViewer(host, options) {
|
|
|
279
420
|
'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
|
|
280
421
|
});
|
|
281
422
|
spans(section, node.colspan);
|
|
423
|
+
const row = labelledRow(node, plan);
|
|
424
|
+
if (row)
|
|
425
|
+
section.setAttribute('data-row', row);
|
|
282
426
|
if (node.labelWidth)
|
|
283
427
|
section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
|
|
284
428
|
const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
|
|
@@ -327,13 +471,39 @@ export function mountViewer(host, options) {
|
|
|
327
471
|
const tabId = uid(`${tab.id}-tab`);
|
|
328
472
|
const panelId = uid(`${tab.id}-panel`);
|
|
329
473
|
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
|
-
|
|
474
|
+
const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId });
|
|
475
|
+
/**
|
|
476
|
+
* A tab's parts are drawn when it is first shown, and kept up to date
|
|
477
|
+
* only while it is shown: a page of many tabs costs one tab's work.
|
|
478
|
+
*/
|
|
479
|
+
const own = [];
|
|
480
|
+
let built = false;
|
|
481
|
+
const build = () => {
|
|
482
|
+
if (built)
|
|
483
|
+
return;
|
|
484
|
+
built = true;
|
|
485
|
+
const outer = sink;
|
|
486
|
+
sink = own;
|
|
487
|
+
try {
|
|
488
|
+
// A tab's parts sit where the tabs do: on the page, or in the box round them.
|
|
489
|
+
panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
|
|
490
|
+
}
|
|
491
|
+
finally {
|
|
492
|
+
sink = outer;
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
const open = () => !form.node(tab.id).invisible && (show(tab.id), true);
|
|
332
496
|
button.addEventListener('click', () => show(tab.id));
|
|
333
497
|
if (!tabs.has(tab.id))
|
|
334
|
-
tabs.set(tab.id,
|
|
498
|
+
tabs.set(tab.id, open);
|
|
499
|
+
// The fields it holds, to mark it while one has a problem and to open it at one.
|
|
500
|
+
const fields = fieldsIn(tab.children);
|
|
501
|
+
if (scope.root)
|
|
502
|
+
for (const name of fields)
|
|
503
|
+
if (!problemTabs.has(name))
|
|
504
|
+
problemTabs.set(name, open);
|
|
335
505
|
list.append(button);
|
|
336
|
-
return { tab, button, panel };
|
|
506
|
+
return { tab, button, panel, own, build, fields };
|
|
337
507
|
});
|
|
338
508
|
list.addEventListener('keydown', (event) => {
|
|
339
509
|
const shown = parts.filter((p) => !p.button.hidden);
|
|
@@ -347,33 +517,76 @@ export function mountViewer(host, options) {
|
|
|
347
517
|
show(next.tab.id);
|
|
348
518
|
next.button.focus();
|
|
349
519
|
});
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
520
|
+
// Too long for its room, the strip scrolls; the tab chosen, however, comes into sight in it.
|
|
521
|
+
const strip = tabStrip(list, el);
|
|
522
|
+
cleanups.push(strip.destroy);
|
|
523
|
+
box.append(strip.element, ...parts.map((p) => p.panel));
|
|
524
|
+
let shownKey = '';
|
|
525
|
+
let revealed = '';
|
|
526
|
+
watch((state) => {
|
|
527
|
+
const tabsHidden = form.node(node.id).invisible;
|
|
528
|
+
setHidden(box, tabsHidden);
|
|
353
529
|
const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
|
|
354
530
|
const keep = picked !== null && shown.some((p) => p.tab.id === picked);
|
|
355
531
|
if (keep)
|
|
356
532
|
active = picked;
|
|
357
533
|
else if (shown.length)
|
|
358
534
|
active = shown[0].tab.id;
|
|
535
|
+
const errors = form.getState().errors;
|
|
359
536
|
for (const p of parts) {
|
|
360
537
|
const selected = p.tab.id === active;
|
|
361
|
-
p.button
|
|
362
|
-
p.button
|
|
363
|
-
p.button.tabIndex
|
|
364
|
-
|
|
538
|
+
setHidden(p.button, !shown.includes(p));
|
|
539
|
+
setAttr(p.button, 'aria-selected', String(selected));
|
|
540
|
+
if (p.button.tabIndex !== (selected ? 0 : -1))
|
|
541
|
+
p.button.tabIndex = selected ? 0 : -1;
|
|
542
|
+
setAttr(p.button, 'data-problem', p.fields.some((name) => errors[name]) ? '' : null);
|
|
543
|
+
setHidden(p.panel, !selected);
|
|
544
|
+
// The tab shown: drawn if it is new, and brought up to date — it was left alone while out of sight.
|
|
545
|
+
if (selected && !tabsHidden) {
|
|
546
|
+
p.build();
|
|
547
|
+
for (const update of p.own)
|
|
548
|
+
update(state);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
// Measured only when it can have changed: the strip's tabs, or the one chosen.
|
|
552
|
+
const key = shown.map((p) => p.tab.id).join(' ');
|
|
553
|
+
if (key !== shownKey) {
|
|
554
|
+
shownKey = key;
|
|
555
|
+
strip.measure();
|
|
556
|
+
}
|
|
557
|
+
if (active !== revealed && list.clientWidth) {
|
|
558
|
+
revealed = active;
|
|
559
|
+
const chosen = parts.find((p) => p.tab.id === active);
|
|
560
|
+
if (chosen)
|
|
561
|
+
strip.reveal(chosen.button);
|
|
365
562
|
}
|
|
366
563
|
});
|
|
367
564
|
return box;
|
|
368
565
|
}
|
|
566
|
+
/** The fields a tab holds, in its sections and tabs inside it; a saved form placed in it keeps its own. */
|
|
567
|
+
function fieldsIn(children) {
|
|
568
|
+
return children.flatMap((child) => child.type === 'field' ? [child.field]
|
|
569
|
+
: child.type === 'section' ? fieldsIn(child.children)
|
|
570
|
+
: child.type === 'tabs' ? child.children.flatMap((tab) => fieldsIn(tab.children))
|
|
571
|
+
: []);
|
|
572
|
+
}
|
|
369
573
|
function item(node, place) {
|
|
574
|
+
return hiddenOn(node, partItem(node, place));
|
|
575
|
+
}
|
|
576
|
+
/** A part hidden at some widths of the form (`hideOn`), as a phone's: the stylesheet hides it there. */
|
|
577
|
+
function hiddenOn(node, element) {
|
|
578
|
+
if (node.hideOn?.length)
|
|
579
|
+
element.dataset['hideOn'] = node.hideOn.join(' ');
|
|
580
|
+
return element;
|
|
581
|
+
}
|
|
582
|
+
function partItem(node, place) {
|
|
370
583
|
switch (node.type) {
|
|
371
584
|
case 'field':
|
|
372
585
|
return fieldItem(node, place.labels);
|
|
373
586
|
case 'button':
|
|
374
587
|
return buttonItem(node);
|
|
375
588
|
case 'text':
|
|
376
|
-
return textItem(node);
|
|
589
|
+
return textItem(node, place.inline);
|
|
377
590
|
case 'slot':
|
|
378
591
|
return slotItem(node);
|
|
379
592
|
case 'section':
|
|
@@ -520,9 +733,12 @@ export function mountViewer(host, options) {
|
|
|
520
733
|
else
|
|
521
734
|
locked = false;
|
|
522
735
|
root.toggleAttribute('data-readonly', locked);
|
|
736
|
+
// Conditions read `editing`: parts for editing only, or reading only, follow.
|
|
737
|
+
form.setEditing(!locked);
|
|
523
738
|
render(form.getState());
|
|
524
739
|
});
|
|
525
740
|
root.toggleAttribute('data-readonly', locked);
|
|
741
|
+
form.setEditing(!locked);
|
|
526
742
|
updaters.push(() => {
|
|
527
743
|
if (!editSwitch)
|
|
528
744
|
return;
|
|
@@ -620,7 +836,15 @@ export function mountViewer(host, options) {
|
|
|
620
836
|
draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
|
|
621
837
|
});
|
|
622
838
|
function focusFirstProblem() {
|
|
623
|
-
|
|
839
|
+
// A problem in a tab out of sight, or not drawn yet: its tab shown first, the first such in the page's order.
|
|
840
|
+
const seen = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]'));
|
|
841
|
+
if (!seen) {
|
|
842
|
+
const errors = form.getState().errors;
|
|
843
|
+
const first = [...problemTabs.keys()].find((name) => errors[name]);
|
|
844
|
+
if (first)
|
|
845
|
+
problemTabs.get(first)?.();
|
|
846
|
+
}
|
|
847
|
+
const invalid = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]')) ?? null;
|
|
624
848
|
if (!invalid)
|
|
625
849
|
return;
|
|
626
850
|
for (let folded = invalid.closest('.fd-section-folded'); folded; folded = folded.parentElement?.closest('.fd-section-folded') ?? null) {
|
|
@@ -655,6 +879,52 @@ export function mountViewer(host, options) {
|
|
|
655
879
|
content.hidden = false;
|
|
656
880
|
done.hidden = true;
|
|
657
881
|
});
|
|
882
|
+
/** 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. */
|
|
883
|
+
function recordName() {
|
|
884
|
+
const state = form.getState();
|
|
885
|
+
if (state.recordId == null)
|
|
886
|
+
return '';
|
|
887
|
+
const field = page.layout.type === 'sheet' ? page.layout.title?.field : undefined;
|
|
888
|
+
const name = field ? displayValue(page.fields[field], state.values[field], state.values, locale) : '';
|
|
889
|
+
return name || page.title || '';
|
|
890
|
+
}
|
|
891
|
+
/** The bar over a record — its breadcrumbs, its gear menu, its pager — or null when it would hold nothing. */
|
|
892
|
+
function topBar(toolbar) {
|
|
893
|
+
if (page.data.kind !== 'record')
|
|
894
|
+
return null;
|
|
895
|
+
const bar = recordBar({
|
|
896
|
+
doc,
|
|
897
|
+
el,
|
|
898
|
+
form,
|
|
899
|
+
toolbar,
|
|
900
|
+
records: options.records,
|
|
901
|
+
breadcrumbs: options.breadcrumbs,
|
|
902
|
+
labels,
|
|
903
|
+
icons: options.icons,
|
|
904
|
+
uid,
|
|
905
|
+
fill,
|
|
906
|
+
withIcon,
|
|
907
|
+
press,
|
|
908
|
+
name: recordName,
|
|
909
|
+
render: renderNow,
|
|
910
|
+
async leave() {
|
|
911
|
+
if (!form.getState().dirty.length)
|
|
912
|
+
return true;
|
|
913
|
+
const saved = await form.save();
|
|
914
|
+
if (!saved) {
|
|
915
|
+
if (!form.getState().saveProblem)
|
|
916
|
+
announce(checkText(form.getState()));
|
|
917
|
+
focusFirstProblem();
|
|
918
|
+
}
|
|
919
|
+
return saved;
|
|
920
|
+
},
|
|
921
|
+
});
|
|
922
|
+
if (!bar)
|
|
923
|
+
return null;
|
|
924
|
+
updaters.push(bar.update);
|
|
925
|
+
cleanups.push(bar.destroy);
|
|
926
|
+
return bar.element;
|
|
927
|
+
}
|
|
658
928
|
function pageHead() {
|
|
659
929
|
if (!page.title && !page.description)
|
|
660
930
|
return null;
|
|
@@ -666,8 +936,20 @@ export function mountViewer(host, options) {
|
|
|
666
936
|
return head;
|
|
667
937
|
}
|
|
668
938
|
// ---- the four page layouts ---------------------------------------------------
|
|
939
|
+
/**
|
|
940
|
+
* A page's own buttons at its foot, as a wizard's, in place of its Save and
|
|
941
|
+
* Discard; null when it has none, or when a dialog round it draws them.
|
|
942
|
+
*/
|
|
943
|
+
function pageFooter(footer) {
|
|
944
|
+
if (!footer?.length || options.showActions === false)
|
|
945
|
+
return null;
|
|
946
|
+
return el('div', { class: 'fd-actions fd-actions-end fd-page-footer' }, status, ...footer.map(buttonItem));
|
|
947
|
+
}
|
|
669
948
|
function sectionsLayout(node) {
|
|
670
949
|
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
|
|
950
|
+
const own = pageFooter(node.footer);
|
|
951
|
+
if (own)
|
|
952
|
+
return el('div', {}, box, own);
|
|
671
953
|
const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
|
|
672
954
|
const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
|
|
673
955
|
if (page.data.kind === 'record') {
|
|
@@ -786,7 +1068,8 @@ export function mountViewer(host, options) {
|
|
|
786
1068
|
// Save and Discard lead the header bar, or close the sheet when the page puts them at its foot.
|
|
787
1069
|
const atFoot = page.actionsPosition === 'bottom';
|
|
788
1070
|
const foot = el('div', { class: 'fd-actions fd-actions-end fd-sheet-foot' });
|
|
789
|
-
|
|
1071
|
+
const own = pageFooter(node.footer);
|
|
1072
|
+
if (options.showActions === false || own)
|
|
790
1073
|
actions.append(...(node.buttons ?? []).map(buttonItem));
|
|
791
1074
|
else if (atFoot) {
|
|
792
1075
|
actions.append(...(node.buttons ?? []).map(buttonItem));
|
|
@@ -807,31 +1090,89 @@ export function mountViewer(host, options) {
|
|
|
807
1090
|
if (node.statusbar && !underTitle)
|
|
808
1091
|
header.append(statusbar(node.statusbar));
|
|
809
1092
|
const card = el('div', { class: 'fd-card' });
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
1093
|
+
const ribbons = [...(node.ribbon ? [node.ribbon] : []), ...(node.ribbons ?? [])];
|
|
1094
|
+
if (ribbons.length) {
|
|
1095
|
+
// The ribbons are clipped to the card's corner by their own frame, so the card itself never clips: lists open past it.
|
|
1096
|
+
const frame = el('div', { class: 'fd-ribbon-frame' });
|
|
1097
|
+
const drawn = ribbons.map((ribbon) => {
|
|
1098
|
+
const element = el('div', { class: `fd-ribbon fd-tone-${ribbon.tone ?? 'muted'}`, 'data-node': ribbon.id, title: ribbon.tooltip }, ribbon.label);
|
|
1099
|
+
frame.append(element);
|
|
1100
|
+
return { ribbon, element };
|
|
1101
|
+
});
|
|
1102
|
+
// One corner, one ribbon: the first whose condition holds; its words from a field when the field holds any.
|
|
1103
|
+
updaters.push((state) => {
|
|
1104
|
+
let shown = false;
|
|
1105
|
+
for (const { ribbon, element } of drawn) {
|
|
1106
|
+
const show = !shown && !form.node(ribbon.id).invisible;
|
|
1107
|
+
shown ||= show;
|
|
1108
|
+
setHidden(element, !show);
|
|
1109
|
+
if (!show)
|
|
1110
|
+
continue;
|
|
1111
|
+
const from = ribbon.labelField ? displayValue(page.fields[ribbon.labelField], state.values[ribbon.labelField], state.values, locale) : '';
|
|
1112
|
+
setText(element, from || ribbon.label);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
card.append(frame);
|
|
815
1116
|
}
|
|
816
1117
|
if (node.statButtons?.length) {
|
|
817
1118
|
const stats = el('div', { class: 'fd-stats' });
|
|
1119
|
+
/** A field's value as the field shows it: money with its currency, a date, a count grouped. */
|
|
1120
|
+
const shownValue = (name, values) => name && page.fields[name] ? displayValue(page.fields[name], values[name], values, locale) : '';
|
|
818
1121
|
for (const stat of node.statButtons) {
|
|
819
1122
|
const value = el('span', { class: 'fd-stat-value' });
|
|
1123
|
+
const label = el('span', { class: 'fd-stat-label' }, stat.label);
|
|
1124
|
+
// A second value with words of its own: the two one over the other, each after its words.
|
|
1125
|
+
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
1126
|
const icon = drawIcon(doc, stat.icon, options.icons);
|
|
821
|
-
const words =
|
|
822
|
-
|
|
1127
|
+
const words = second
|
|
1128
|
+
? 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))
|
|
1129
|
+
: el('span', { class: 'fd-stat-words' }, value, label);
|
|
1130
|
+
// Its help, as Flectra's help=: its tooltip, and its description for a screen reader — never its name.
|
|
1131
|
+
const helpId = stat.help ? uid(`${stat.id}-help`) : undefined;
|
|
1132
|
+
const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id, title: stat.help || undefined, 'aria-describedby': helpId }, ...(icon ? [icon, words] : [words]));
|
|
1133
|
+
if (helpId)
|
|
1134
|
+
stats.append(el('span', { class: 'fd-sr-only', id: helpId }, stat.help));
|
|
823
1135
|
button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
|
|
824
1136
|
updaters.push((state) => {
|
|
825
1137
|
button.hidden = form.node(stat.id).invisible;
|
|
826
|
-
const
|
|
827
|
-
|
|
1138
|
+
const values = state.values;
|
|
1139
|
+
const unit = shownValue(stat.unitField, values) || stat.unit || '';
|
|
1140
|
+
const first = shownValue(stat.field, values);
|
|
1141
|
+
const other = shownValue(stat.secondField, values);
|
|
1142
|
+
if (second) {
|
|
1143
|
+
setText(value, first);
|
|
1144
|
+
setText(second.value, [other, unit].filter(Boolean).join(' '));
|
|
1145
|
+
}
|
|
1146
|
+
else
|
|
1147
|
+
setText(value, [[first, other].filter(Boolean).join(' / '), first || other ? unit : ''].filter(Boolean).join(' '));
|
|
1148
|
+
setText(label, shownValue(stat.labelField, values) || stat.label);
|
|
828
1149
|
});
|
|
829
1150
|
stats.append(button);
|
|
830
1151
|
}
|
|
1152
|
+
// No empty row while every button is hidden, as with the badges.
|
|
1153
|
+
updaters.push(() => {
|
|
1154
|
+
stats.hidden = [...stats.querySelectorAll(':scope > .fd-stat')].every((stat) => stat.hidden);
|
|
1155
|
+
});
|
|
831
1156
|
card.append(stats);
|
|
832
1157
|
}
|
|
833
1158
|
for (const alert of node.alerts ?? []) {
|
|
834
|
-
|
|
1159
|
+
// Its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled.
|
|
1160
|
+
const words = valueWords(doc, page.fields, alert.message, locale);
|
|
1161
|
+
const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
|
|
1162
|
+
let fromField = false;
|
|
1163
|
+
const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
|
|
1164
|
+
if (alert.buttons?.length)
|
|
1165
|
+
box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
|
|
1166
|
+
updaters.push((state) => {
|
|
1167
|
+
words.update(state.values);
|
|
1168
|
+
const from = alert.messageField ? state.values[alert.messageField] : null;
|
|
1169
|
+
const said = typeof from === 'string' ? from.trim() : '';
|
|
1170
|
+
if (said)
|
|
1171
|
+
setText(message, said);
|
|
1172
|
+
else if (fromField)
|
|
1173
|
+
message.replaceChildren(...words.nodes);
|
|
1174
|
+
fromField = !!said;
|
|
1175
|
+
});
|
|
835
1176
|
if (alert.dismissible) {
|
|
836
1177
|
// Closed, it stays closed while the page is open, whatever its condition does.
|
|
837
1178
|
let dismissed = false;
|
|
@@ -863,9 +1204,26 @@ export function mountViewer(host, options) {
|
|
|
863
1204
|
}
|
|
864
1205
|
if (node.title) {
|
|
865
1206
|
const title = el('div', { class: 'fd-title' });
|
|
1207
|
+
// Words over the title, as Flectra's "Product Name" over its h1, naming its box.
|
|
1208
|
+
if (node.title.label)
|
|
1209
|
+
title.append(el('label', { class: 'fd-title-label', for: uid('#title') }, node.title.label));
|
|
866
1210
|
if (node.title.above?.length)
|
|
867
1211
|
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
|
|
868
|
-
|
|
1212
|
+
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 } : {}) });
|
|
1213
|
+
// Fields on the title's line, before and after it: a priority star, a state's dot (Flectra's <h1>).
|
|
1214
|
+
const before = node.title.before ?? [];
|
|
1215
|
+
const after = node.title.after ?? [];
|
|
1216
|
+
if (before.length || after.length) {
|
|
1217
|
+
name.classList.add('fd-title-name');
|
|
1218
|
+
const side = (part) => {
|
|
1219
|
+
const item = fieldItem(part);
|
|
1220
|
+
item.classList.add('fd-title-side');
|
|
1221
|
+
return item;
|
|
1222
|
+
};
|
|
1223
|
+
title.append(el('div', { class: 'fd-title-line' }, ...before.map(side), name, ...after.map(side)));
|
|
1224
|
+
}
|
|
1225
|
+
else
|
|
1226
|
+
title.append(name);
|
|
869
1227
|
if (node.title.subtitleField)
|
|
870
1228
|
title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
|
|
871
1229
|
if (node.title.below?.length)
|
|
@@ -881,6 +1239,8 @@ export function mountViewer(host, options) {
|
|
|
881
1239
|
if (node.statusbar && underTitle)
|
|
882
1240
|
card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
|
|
883
1241
|
card.append(grid(node.children, 1, top(false)));
|
|
1242
|
+
if (own)
|
|
1243
|
+
card.append(own);
|
|
884
1244
|
if (atFoot && options.showActions !== false) {
|
|
885
1245
|
card.append(foot);
|
|
886
1246
|
updaters.push((state) => {
|
|
@@ -888,11 +1248,22 @@ export function mountViewer(host, options) {
|
|
|
888
1248
|
});
|
|
889
1249
|
}
|
|
890
1250
|
const layout = el('div', { class: 'fd-sheet-layout' }, card);
|
|
1251
|
+
if (node.attachmentPreview) {
|
|
1252
|
+
// Beside the sheet on a wide form, the side panel then under the sheet; under the sheet on a narrow one.
|
|
1253
|
+
const preview = attachmentPreview({ doc, el, form, page, node: node.attachmentPreview, dataSource: options.dataSource, labels, fill });
|
|
1254
|
+
layout.classList.add('fd-has-preview');
|
|
1255
|
+
layout.append(preview.element);
|
|
1256
|
+
updaters.push(preview.update);
|
|
1257
|
+
cleanups.push(preview.destroy);
|
|
1258
|
+
}
|
|
891
1259
|
if (node.sidePanel) {
|
|
892
1260
|
layout.classList.add('fd-has-side');
|
|
1261
|
+
if (node.sidePanelBeside)
|
|
1262
|
+
layout.setAttribute('data-side-beside', node.sidePanelBeside);
|
|
893
1263
|
layout.append(el('aside', { class: 'fd-side' }, slotItem(node.sidePanel)));
|
|
894
1264
|
}
|
|
895
|
-
|
|
1265
|
+
const bar = topBar(node.toolbar);
|
|
1266
|
+
pageBox.append(...(bar ? [bar] : []), header, layout);
|
|
896
1267
|
return pageBox;
|
|
897
1268
|
}
|
|
898
1269
|
/** The sheet header's statusbar: the same widget a form can place anywhere. */
|
|
@@ -901,9 +1272,18 @@ export function mountViewer(host, options) {
|
|
|
901
1272
|
const barOptions = { clickable: config.clickable === true };
|
|
902
1273
|
if (config.visibleStates)
|
|
903
1274
|
barOptions['visibleStates'] = config.visibleStates;
|
|
1275
|
+
for (const key of ['durationsField', 'fold', 'saves'])
|
|
1276
|
+
if (config[key] !== undefined)
|
|
1277
|
+
barOptions[key] = config[key];
|
|
904
1278
|
const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
|
|
905
1279
|
const widget = createWidget({ form, name: config.field, field: def, node, id: uid('statusbar'), document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
906
|
-
|
|
1280
|
+
if (widget.destroy)
|
|
1281
|
+
cleanups.push(() => widget.destroy?.());
|
|
1282
|
+
updaters.push((state) => {
|
|
1283
|
+
// Hidden while its own condition holds, or from people outside its roles.
|
|
1284
|
+
setHidden(widget.element, form.node('#statusbar').invisible);
|
|
1285
|
+
widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false });
|
|
1286
|
+
});
|
|
907
1287
|
return widget.element;
|
|
908
1288
|
}
|
|
909
1289
|
let wizardForward = null;
|
|
@@ -932,6 +1312,7 @@ export function mountViewer(host, options) {
|
|
|
932
1312
|
icons: options.icons,
|
|
933
1313
|
preferences,
|
|
934
1314
|
onOpenRecord: options.onOpenRecord,
|
|
1315
|
+
...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
|
|
935
1316
|
});
|
|
936
1317
|
cleanups.push(list.destroy);
|
|
937
1318
|
body = list.element;
|
|
@@ -939,7 +1320,9 @@ export function mountViewer(host, options) {
|
|
|
939
1320
|
else
|
|
940
1321
|
body = sectionsLayout(layout);
|
|
941
1322
|
const head = layout.type === 'sheet' ? null : pageHead();
|
|
942
|
-
|
|
1323
|
+
// A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
|
|
1324
|
+
const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
|
|
1325
|
+
content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
|
|
943
1326
|
root.append(content, done, announcer);
|
|
944
1327
|
// The status leaves its place beside Save when the page wants it in a corner or across the top.
|
|
945
1328
|
if (options.saveStatus === 'toast') {
|
|
@@ -1014,6 +1397,46 @@ export function mountViewer(host, options) {
|
|
|
1014
1397
|
event.preventDefault();
|
|
1015
1398
|
nextField(from)?.focus();
|
|
1016
1399
|
});
|
|
1400
|
+
// ---- a button's key, with Alt -------------------------------------------------
|
|
1401
|
+
/**
|
|
1402
|
+
* Whether a key pressed with Alt is this form's: pressed in it (not in a
|
|
1403
|
+
* form inside it, such as a dialog's), or — pressed outside any form — this
|
|
1404
|
+
* is the first form on the page, or in the question or dialog on top of it.
|
|
1405
|
+
*/
|
|
1406
|
+
function hotkeysHere(target) {
|
|
1407
|
+
const inner = target instanceof Element ? target.closest('.fd-form') : null;
|
|
1408
|
+
if (inner)
|
|
1409
|
+
return inner === root && !root.querySelector(':scope > .fd-dialog-backdrop');
|
|
1410
|
+
const modals = [...doc.querySelectorAll('[aria-modal="true"]')].filter((modal) => !modal.closest('[hidden]'));
|
|
1411
|
+
const top = modals[modals.length - 1];
|
|
1412
|
+
return (top ?? doc).querySelector('.fd-form') === root;
|
|
1413
|
+
}
|
|
1414
|
+
const onHotkey = (event) => {
|
|
1415
|
+
if (event.key === 'Alt') {
|
|
1416
|
+
if (hotkeysHere(event.target))
|
|
1417
|
+
root.setAttribute('data-hotkeys', '');
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
const key = hotkeyOf(event);
|
|
1421
|
+
if (!key || !hotkeysHere(event.target))
|
|
1422
|
+
return;
|
|
1423
|
+
const button = [...root.querySelectorAll(`button[data-hotkey="${key}"]`)].find((b) => b.closest('.fd-form') === root && !b.closest('[hidden]') && !b.disabled && !b.hasAttribute('aria-busy'));
|
|
1424
|
+
if (!button)
|
|
1425
|
+
return;
|
|
1426
|
+
event.preventDefault();
|
|
1427
|
+
root.removeAttribute('data-hotkeys');
|
|
1428
|
+
button.click();
|
|
1429
|
+
};
|
|
1430
|
+
const offHotkeys = () => root.removeAttribute('data-hotkeys');
|
|
1431
|
+
const altUp = (event) => event.key === 'Alt' && offHotkeys();
|
|
1432
|
+
doc.addEventListener('keydown', onHotkey);
|
|
1433
|
+
doc.addEventListener('keyup', altUp);
|
|
1434
|
+
doc.defaultView?.addEventListener('blur', offHotkeys);
|
|
1435
|
+
cleanups.push(() => {
|
|
1436
|
+
doc.removeEventListener('keydown', onHotkey);
|
|
1437
|
+
doc.removeEventListener('keyup', altUp);
|
|
1438
|
+
doc.defaultView?.removeEventListener('blur', offHotkeys);
|
|
1439
|
+
});
|
|
1017
1440
|
// ---- confirmation dialog -----------------------------------------------------
|
|
1018
1441
|
/** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
|
|
1019
1442
|
function dialogConfirm(message, withCancel = true) {
|
|
@@ -1063,6 +1486,24 @@ export function mountViewer(host, options) {
|
|
|
1063
1486
|
}));
|
|
1064
1487
|
render(form.getState());
|
|
1065
1488
|
mounted = true;
|
|
1489
|
+
// As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
|
|
1490
|
+
// only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
|
|
1491
|
+
let opened = false;
|
|
1492
|
+
if (focusers.length) {
|
|
1493
|
+
cleanups.push(form.on('open', () => {
|
|
1494
|
+
const first = !opened;
|
|
1495
|
+
opened = true;
|
|
1496
|
+
queueMicrotask(() => {
|
|
1497
|
+
if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
|
|
1498
|
+
return;
|
|
1499
|
+
const active = doc.activeElement;
|
|
1500
|
+
const free = !active || active === doc.body || active === doc.documentElement;
|
|
1501
|
+
if (!free && !(first && root.contains(active)))
|
|
1502
|
+
return;
|
|
1503
|
+
focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
|
|
1504
|
+
});
|
|
1505
|
+
}));
|
|
1506
|
+
}
|
|
1066
1507
|
if (form.getState().status === 'idle')
|
|
1067
1508
|
void form.load();
|
|
1068
1509
|
return {
|
|
@@ -1086,6 +1527,7 @@ export function mountViewer(host, options) {
|
|
|
1086
1527
|
setReadonly(readonly) {
|
|
1087
1528
|
locked = readonly;
|
|
1088
1529
|
root.toggleAttribute('data-readonly', locked);
|
|
1530
|
+
form.setEditing(!locked);
|
|
1089
1531
|
render(form.getState());
|
|
1090
1532
|
},
|
|
1091
1533
|
isReadonly: () => locked,
|