@fieldia/viewer 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -1
- package/bundle/fieldia.ar.js +3 -0
- package/bundle/fieldia.ar.js.map +7 -0
- package/bundle/fieldia.de.js +3 -0
- package/bundle/fieldia.de.js.map +7 -0
- package/bundle/fieldia.fr.js +3 -0
- package/bundle/fieldia.fr.js.map +7 -0
- package/bundle/fieldia.js +4 -4
- package/bundle/fieldia.js.map +4 -4
- package/package.json +6 -3
- package/src/index.d.mts +4 -0
- package/src/index.d.ts +4 -0
- package/src/index.js +12 -0
- package/src/index.js.map +1 -1
- package/src/index.mjs +4 -0
- package/src/index.mjs.map +1 -1
- package/src/lib/add-language.d.mts +20 -0
- package/src/lib/add-language.d.ts +20 -0
- package/src/lib/add-language.js +19 -0
- package/src/lib/add-language.js.map +1 -0
- package/src/lib/add-language.mjs +16 -0
- package/src/lib/add-language.mjs.map +1 -0
- package/src/lib/dialog.d.mts +7 -2
- package/src/lib/dialog.d.ts +7 -2
- package/src/lib/dialog.js +16 -21
- package/src/lib/dialog.js.map +1 -1
- package/src/lib/dialog.mjs +16 -21
- package/src/lib/dialog.mjs.map +1 -1
- package/src/lib/dom.d.mts +10 -0
- package/src/lib/dom.d.ts +10 -0
- package/src/lib/dom.js +26 -0
- package/src/lib/dom.js.map +1 -1
- package/src/lib/dom.mjs +23 -1
- package/src/lib/dom.mjs.map +1 -1
- package/src/lib/focus-trap.d.mts +2 -0
- package/src/lib/focus-trap.d.ts +2 -0
- package/src/lib/focus-trap.js +7 -0
- package/src/lib/focus-trap.js.map +1 -0
- package/src/lib/focus-trap.mjs +3 -0
- package/src/lib/focus-trap.mjs.map +1 -0
- package/src/lib/labels.d.mts +13 -0
- package/src/lib/labels.d.ts +13 -0
- package/src/lib/labels.js +103 -277
- package/src/lib/labels.js.map +1 -1
- package/src/lib/labels.mjs +102 -277
- package/src/lib/labels.mjs.map +1 -1
- package/src/lib/list-styles.d.mts +1 -1
- package/src/lib/list-styles.d.ts +1 -1
- package/src/lib/list-styles.js +3 -2
- package/src/lib/list-styles.js.map +1 -1
- package/src/lib/list-styles.mjs +3 -2
- package/src/lib/list-styles.mjs.map +1 -1
- package/src/lib/locales/all.d.mts +8 -0
- package/src/lib/locales/all.d.ts +8 -0
- package/src/lib/locales/all.js +13 -0
- package/src/lib/locales/all.js.map +1 -0
- package/src/lib/locales/all.mjs +10 -0
- package/src/lib/locales/all.mjs.map +1 -0
- package/src/lib/locales/ar.d.mts +3 -0
- package/src/lib/locales/ar.d.ts +3 -0
- package/src/lib/locales/ar.js +74 -0
- package/src/lib/locales/ar.js.map +1 -0
- package/src/lib/locales/ar.mjs +71 -0
- package/src/lib/locales/ar.mjs.map +1 -0
- package/src/lib/locales/de.d.mts +3 -0
- package/src/lib/locales/de.d.ts +3 -0
- package/src/lib/locales/de.js +74 -0
- package/src/lib/locales/de.js.map +1 -0
- package/src/lib/locales/de.mjs +71 -0
- package/src/lib/locales/de.mjs.map +1 -0
- package/src/lib/locales/fr.d.mts +3 -0
- package/src/lib/locales/fr.d.ts +3 -0
- package/src/lib/locales/fr.js +74 -0
- package/src/lib/locales/fr.js.map +1 -0
- package/src/lib/locales/fr.mjs +71 -0
- package/src/lib/locales/fr.mjs.map +1 -0
- package/src/lib/look.d.mts +21 -0
- package/src/lib/look.d.ts +21 -0
- package/src/lib/look.js +84 -0
- package/src/lib/look.js.map +1 -0
- package/src/lib/look.mjs +79 -0
- package/src/lib/look.mjs.map +1 -0
- package/src/lib/place.d.mts +35 -0
- package/src/lib/place.d.ts +35 -0
- package/src/lib/place.js +37 -0
- package/src/lib/place.js.map +1 -0
- package/src/lib/place.mjs +33 -0
- package/src/lib/place.mjs.map +1 -0
- package/src/lib/related.js +2 -1
- package/src/lib/related.js.map +1 -1
- package/src/lib/related.mjs +2 -1
- package/src/lib/related.mjs.map +1 -1
- package/src/lib/viewer.d.mts +10 -2
- package/src/lib/viewer.d.ts +10 -2
- package/src/lib/viewer.js +160 -57
- package/src/lib/viewer.js.map +1 -1
- package/src/lib/viewer.mjs +162 -59
- package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
-
import { createForm, checkPage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
|
|
1
|
+
import { createForm, checkPage, isRightToLeft, localizePage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
|
|
2
2
|
import { browserPreferences, createWidget, drawIcon, installStyles, WIDGET_LABELS } from '@fieldia/widgets';
|
|
3
3
|
import { pageDialogs } from './related.mjs';
|
|
4
4
|
import { listView } from './list.mjs';
|
|
5
|
-
import {
|
|
5
|
+
import { applyLook } from './look.mjs';
|
|
6
|
+
import { labelPlace, planSection } from './place.mjs';
|
|
7
|
+
import { setAttr, setHidden, setText } from './dom.mjs';
|
|
8
|
+
import { ownLocale, VIEWER_LABELS } from './labels.mjs';
|
|
9
|
+
import { keepTabIn } from './focus-trap.mjs';
|
|
6
10
|
export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
|
|
7
11
|
let mounts = 0;
|
|
8
12
|
/**
|
|
@@ -17,15 +21,18 @@ export function mountViewer(host, options) {
|
|
|
17
21
|
if (!checked.ok) {
|
|
18
22
|
throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
|
|
19
23
|
}
|
|
20
|
-
|
|
21
|
-
const
|
|
24
|
+
// The page's own words in its language first; the app's translator over what is left.
|
|
25
|
+
const tag = options.locale ?? checked.page.language;
|
|
26
|
+
const localized = tag ? localizePage(checked.page, tag) : checked.page;
|
|
27
|
+
const page = options.translate ? translatePage(localized, options.translate) : localized;
|
|
28
|
+
const locale = ownLocale(tag);
|
|
22
29
|
const preferences = options.preferences ?? browserPreferences();
|
|
23
30
|
const dialogs = pageDialogs(options);
|
|
24
31
|
const ownsForm = !options.form;
|
|
25
32
|
const form = options.form ?? createForm({ ...options, page, messages: options.messages ?? MESSAGES[locale] });
|
|
26
33
|
const labels = { ...VIEWER_LABELS[locale], ...options.labels };
|
|
27
34
|
const widgetLabels = WIDGET_LABELS[locale];
|
|
28
|
-
const dir = options.dir ?? (
|
|
35
|
+
const dir = options.dir ?? (tag && isRightToLeft(tag) ? 'rtl' : undefined);
|
|
29
36
|
const prefix = `fd${++mounts}`;
|
|
30
37
|
const updaters = [];
|
|
31
38
|
const cleanups = [];
|
|
@@ -40,21 +47,30 @@ export function mountViewer(host, options) {
|
|
|
40
47
|
node.append(...children);
|
|
41
48
|
return node;
|
|
42
49
|
}
|
|
43
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Words with the icon a page names before them, when there is such an icon.
|
|
52
|
+
* The words sit in a <bdi>: left as written on a page of the other direction,
|
|
53
|
+
* they still run their own way, the icon still before them.
|
|
54
|
+
*/
|
|
44
55
|
function withIcon(name, text) {
|
|
45
56
|
const icon = drawIcon(doc, name, options.icons);
|
|
46
|
-
|
|
57
|
+
const words = el('bdi', {}, text);
|
|
58
|
+
return icon ? [icon, words] : [words];
|
|
47
59
|
}
|
|
48
|
-
const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang:
|
|
60
|
+
const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: tag, 'data-max-width': page.maxWidth });
|
|
61
|
+
applyLook(root, page.look);
|
|
49
62
|
const confirm = options.confirm ?? dialogConfirm;
|
|
50
63
|
// ---- the parts -------------------------------------------------------
|
|
51
|
-
|
|
64
|
+
/** A field with its label, help and messages. `labels` is where labels sit where it is put; the title's own fields take none. */
|
|
65
|
+
function fieldItem(node, labels) {
|
|
52
66
|
const def = page.fields[node.field];
|
|
53
67
|
const id = uid(node.id);
|
|
54
|
-
const
|
|
68
|
+
const labelsAt = labelPlace(node, def.type, labels);
|
|
69
|
+
const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-type': def.type, 'data-labels': labelsAt });
|
|
55
70
|
if (node.colspan)
|
|
56
71
|
wrapper.style.setProperty('--fd-span', String(node.colspan));
|
|
57
|
-
const
|
|
72
|
+
const labelText = node.label ?? def.label;
|
|
73
|
+
const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, labelText);
|
|
58
74
|
// The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
|
|
59
75
|
const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? drawIcon(doc, 'check') : null;
|
|
60
76
|
if (mark) {
|
|
@@ -62,33 +78,56 @@ export function mountViewer(host, options) {
|
|
|
62
78
|
label.append(mark);
|
|
63
79
|
}
|
|
64
80
|
const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
81
|
+
// 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.
|
|
82
|
+
if (labelsAt === 'hidden' && !node.placeholder) {
|
|
83
|
+
const box = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
|
|
84
|
+
box?.setAttribute('placeholder', labelText);
|
|
85
|
+
}
|
|
65
86
|
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
|
|
66
87
|
// `for` stays: a custom field that puts the id on its own input is
|
|
67
88
|
// labelled natively. Only a wrapper with a role (a radio group, say) may
|
|
68
89
|
// carry a name; ARIA forbids naming a plain div or span.
|
|
69
|
-
|
|
70
|
-
|
|
90
|
+
// A group inside a box of its own (a radio group with "Clear selection" under it) is named too.
|
|
91
|
+
const named = widget.element.getAttribute('role') ? widget.element : widget.element.querySelector(`[id="${id}"][role]`);
|
|
92
|
+
named?.setAttribute('aria-labelledby', label.id);
|
|
71
93
|
label.addEventListener('click', () => widget.focus());
|
|
72
94
|
}
|
|
73
95
|
const helpText = node.help ?? def.help;
|
|
74
96
|
const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
|
|
75
97
|
// Not an alert of its own: a refused save is announced once, naming every field to look at.
|
|
76
98
|
const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
|
|
77
|
-
// A warning from the data source's onchange
|
|
78
|
-
const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
|
|
79
|
-
wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
|
|
99
|
+
// A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
|
|
100
|
+
const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
|
|
101
|
+
wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
|
|
80
102
|
if (widget.destroy)
|
|
81
103
|
cleanups.push(() => widget.destroy?.());
|
|
82
|
-
|
|
104
|
+
// An answer rule's warning waits until the person leaves the field: no advice
|
|
105
|
+
// mid-word. One already shown stays while they put it right, and goes once they have.
|
|
106
|
+
let typing = false;
|
|
107
|
+
let advice = '';
|
|
108
|
+
wrapper.addEventListener('focusin', () => (typing = true));
|
|
109
|
+
wrapper.addEventListener('focusout', (event) => {
|
|
110
|
+
if (wrapper.contains(event.relatedTarget))
|
|
111
|
+
return;
|
|
112
|
+
typing = false;
|
|
113
|
+
// Not at once: focus also leaves when a redraw removes the focused part
|
|
114
|
+
// (a deleted line's button), and that redraw must finish first.
|
|
115
|
+
queueMicrotask(() => update(form.getState()));
|
|
116
|
+
});
|
|
117
|
+
const update = (state) => {
|
|
83
118
|
const shown = form.node(node.id);
|
|
84
|
-
wrapper
|
|
119
|
+
setHidden(wrapper, shown.invisible);
|
|
85
120
|
wrapper.classList.toggle('fd-required', shown.required);
|
|
86
121
|
const message = state.errors[node.field];
|
|
87
|
-
error
|
|
88
|
-
error
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
122
|
+
setHidden(error, !message);
|
|
123
|
+
setText(error, message ?? '');
|
|
124
|
+
const now = state.warnings[node.field] ?? '';
|
|
125
|
+
if (!typing || !now)
|
|
126
|
+
advice = now;
|
|
127
|
+
// An error shown says enough: the rule's warning gives way to it.
|
|
128
|
+
const warned = [message ? '' : advice, state.warning && state.warningField === node.field ? state.warning : ''].filter(Boolean).join(' ');
|
|
129
|
+
setHidden(warning, !warned);
|
|
130
|
+
setText(warning, warned);
|
|
92
131
|
if (mark) {
|
|
93
132
|
const value = state.values[node.field];
|
|
94
133
|
const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
|
|
@@ -102,18 +141,18 @@ export function mountViewer(host, options) {
|
|
|
102
141
|
readonly: shown.readonly || locked,
|
|
103
142
|
required: shown.required,
|
|
104
143
|
invalid: !!message,
|
|
105
|
-
describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
144
|
+
describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
106
145
|
});
|
|
107
|
-
}
|
|
146
|
+
};
|
|
147
|
+
updaters.push(update);
|
|
108
148
|
return wrapper;
|
|
109
149
|
}
|
|
110
150
|
function hideWhen(element, id) {
|
|
111
|
-
updaters.push(() =>
|
|
112
|
-
element.hidden = form.node(id).invisible;
|
|
113
|
-
});
|
|
151
|
+
updaters.push(() => setHidden(element, form.node(id).invisible));
|
|
114
152
|
}
|
|
115
153
|
function buttonItem(node) {
|
|
116
154
|
const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
|
|
155
|
+
spans(button, node.colspan);
|
|
117
156
|
button.addEventListener('click', async () => {
|
|
118
157
|
if (node.confirm && !(await confirm(node.confirm)))
|
|
119
158
|
return;
|
|
@@ -125,6 +164,7 @@ export function mountViewer(host, options) {
|
|
|
125
164
|
function textItem(node) {
|
|
126
165
|
const style = node.style ?? 'paragraph';
|
|
127
166
|
const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
|
|
167
|
+
spans(element, node.colspan);
|
|
128
168
|
hideWhen(element, node.id);
|
|
129
169
|
return element;
|
|
130
170
|
}
|
|
@@ -136,28 +176,52 @@ export function mountViewer(host, options) {
|
|
|
136
176
|
hideWhen(element, node.id);
|
|
137
177
|
return element;
|
|
138
178
|
}
|
|
139
|
-
|
|
179
|
+
/** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
|
|
180
|
+
function spans(element, colspan) {
|
|
181
|
+
if (colspan)
|
|
182
|
+
element.style.setProperty('--fd-span', String(colspan));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* A grid of parts. With `columns` null, it has none of its own: it lays its
|
|
186
|
+
* parts on the columns of the grid round it (an arrangement on its tracks).
|
|
187
|
+
*/
|
|
188
|
+
function grid(children, columns, place) {
|
|
140
189
|
const box = el('div', { class: 'fd-grid' });
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
190
|
+
if (columns !== null) {
|
|
191
|
+
box.style.setProperty('--fd-columns', String(wideColumns(columns)));
|
|
192
|
+
// Counts given for the narrower widths replace the skin's own stacking there.
|
|
193
|
+
if (typeof columns === 'object') {
|
|
194
|
+
for (const width of ['medium', 'narrow']) {
|
|
195
|
+
const count = columns[width];
|
|
196
|
+
if (count === undefined)
|
|
197
|
+
continue;
|
|
198
|
+
box.setAttribute(`data-columns-${width}`, String(count));
|
|
199
|
+
box.style.setProperty(`--fd-columns-${width}`, String(count));
|
|
200
|
+
}
|
|
150
201
|
}
|
|
151
202
|
}
|
|
152
|
-
box.append(...children.map(item));
|
|
203
|
+
box.append(...children.map((child) => item(child, place)));
|
|
153
204
|
return box;
|
|
154
205
|
}
|
|
206
|
+
/** Where the parts of a page, a tab or a step sit: one column, the page's labels. */
|
|
207
|
+
const top = (onPage) => ({ columns: 1, onPage, labels: page.look?.labels });
|
|
155
208
|
/** Folded sections, and how to open each: a problem inside one has to be seen. */
|
|
156
209
|
const folds = new Map();
|
|
157
|
-
function sectionItem(node) {
|
|
158
|
-
const
|
|
210
|
+
function sectionItem(node, place) {
|
|
211
|
+
const plan = planSection(node, place);
|
|
212
|
+
// An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
|
|
213
|
+
const section = el(plan.arrangement ? 'div' : 'fieldset', {
|
|
214
|
+
class: 'fd-section',
|
|
215
|
+
'data-node': node.id,
|
|
216
|
+
'data-style': plan.style,
|
|
217
|
+
'data-place': plan.at,
|
|
218
|
+
'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
|
|
219
|
+
});
|
|
220
|
+
spans(section, node.colspan);
|
|
221
|
+
if (node.labelWidth)
|
|
222
|
+
section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
|
|
159
223
|
const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
|
|
160
|
-
const content = grid(node.children, node.columns);
|
|
224
|
+
const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
|
|
161
225
|
if (node.title && node.collapsible) {
|
|
162
226
|
content.id = uid(`${node.id}-content`);
|
|
163
227
|
const toggle = el('button', { type: 'button', class: 'fd-section-toggle', 'aria-controls': content.id }, el('span', { class: 'fd-section-chevron', 'aria-hidden': 'true' }), ...withIcon(node.icon, node.title));
|
|
@@ -184,8 +248,9 @@ export function mountViewer(host, options) {
|
|
|
184
248
|
hideWhen(section, node.id);
|
|
185
249
|
return section;
|
|
186
250
|
}
|
|
187
|
-
function tabsItem(node) {
|
|
251
|
+
function tabsItem(node, place) {
|
|
188
252
|
const box = el('div', { class: 'fd-tabs', 'data-node': node.id });
|
|
253
|
+
spans(box, node.colspan);
|
|
189
254
|
const list = el('div', { class: 'fd-tablist', role: 'tablist' });
|
|
190
255
|
// The tab someone picked. Until they pick, the first visible tab is open —
|
|
191
256
|
// a tab hidden while the record loads must not leave its neighbour open.
|
|
@@ -195,7 +260,8 @@ export function mountViewer(host, options) {
|
|
|
195
260
|
const tabId = uid(`${tab.id}-tab`);
|
|
196
261
|
const panelId = uid(`${tab.id}-panel`);
|
|
197
262
|
const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, ...withIcon(tab.icon, tab.label));
|
|
198
|
-
|
|
263
|
+
// A tab's parts sit where the tabs do: on the page, or in the box round them.
|
|
264
|
+
const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
|
|
199
265
|
button.addEventListener('click', () => {
|
|
200
266
|
picked = active = tab.id;
|
|
201
267
|
render(form.getState());
|
|
@@ -235,10 +301,10 @@ export function mountViewer(host, options) {
|
|
|
235
301
|
});
|
|
236
302
|
return box;
|
|
237
303
|
}
|
|
238
|
-
function item(node) {
|
|
304
|
+
function item(node, place) {
|
|
239
305
|
switch (node.type) {
|
|
240
306
|
case 'field':
|
|
241
|
-
return fieldItem(node);
|
|
307
|
+
return fieldItem(node, place.labels);
|
|
242
308
|
case 'button':
|
|
243
309
|
return buttonItem(node);
|
|
244
310
|
case 'text':
|
|
@@ -246,11 +312,35 @@ export function mountViewer(host, options) {
|
|
|
246
312
|
case 'slot':
|
|
247
313
|
return slotItem(node);
|
|
248
314
|
case 'section':
|
|
249
|
-
return sectionItem(node);
|
|
315
|
+
return sectionItem(node, place);
|
|
250
316
|
case 'tabs':
|
|
251
|
-
return tabsItem(node);
|
|
317
|
+
return tabsItem(node, place);
|
|
318
|
+
case 'divider':
|
|
319
|
+
case 'spacer':
|
|
320
|
+
case 'image':
|
|
321
|
+
return blockItem(node);
|
|
252
322
|
}
|
|
253
323
|
}
|
|
324
|
+
/** A line across the row, empty room, or a picture: shown and hidden by its rule like any part. */
|
|
325
|
+
function blockItem(node) {
|
|
326
|
+
const element = node.type === 'divider' ? el('hr', { class: 'fd-divider', 'data-node': node.id })
|
|
327
|
+
: node.type === 'spacer' ? el('div', { class: 'fd-block fd-spacer', 'aria-hidden': 'true', 'data-node': node.id })
|
|
328
|
+
: figure(node);
|
|
329
|
+
if (node.type !== 'divider' && node.colspan)
|
|
330
|
+
element.style.setProperty('--fd-span', String(node.colspan));
|
|
331
|
+
hideWhen(element, node.id);
|
|
332
|
+
return element;
|
|
333
|
+
}
|
|
334
|
+
/** A picture as wide and where its page says, opening its link in a new tab — a web or mail address only — its caption under it. */
|
|
335
|
+
function figure(node) {
|
|
336
|
+
const image = el('img', { class: 'fd-image', src: node.src, alt: node.alt, loading: 'lazy' });
|
|
337
|
+
const href = node.href && /^(https?:\/\/|mailto:)/i.test(node.href) ? node.href : undefined;
|
|
338
|
+
const element = el('figure', { class: 'fd-block fd-figure', 'data-node': node.id, 'data-align': node.align }, href ? el('a', { href, target: '_blank', rel: 'noopener noreferrer' }, image) : image, ...(node.caption ? [el('figcaption', { class: 'fd-caption' }, node.caption)] : []));
|
|
339
|
+
const width = typeof node.width === 'number' ? `${node.width}px` : node.width && { small: '160px', medium: '320px', large: '480px', full: '100%' }[node.width];
|
|
340
|
+
if (width)
|
|
341
|
+
element.style.setProperty('--fd-image-width', width);
|
|
342
|
+
return element;
|
|
343
|
+
}
|
|
254
344
|
// ---- shared chrome ----------------------------------------------------------
|
|
255
345
|
/** The whole form locked: every field read-only, no Save; buttons still run. */
|
|
256
346
|
let locked = options.readonly === true;
|
|
@@ -310,8 +400,10 @@ export function mountViewer(host, options) {
|
|
|
310
400
|
: state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
|
|
311
401
|
: state.status === 'error' ? state.error ?? ''
|
|
312
402
|
: '';
|
|
313
|
-
|
|
314
|
-
|
|
403
|
+
// A problem is said once, at once, by the announcer (or the banner, or a dialog): the status shows it and keeps quiet.
|
|
404
|
+
setAttr(statusText, 'aria-live', state.status === 'error' && problem ? 'off' : 'polite');
|
|
405
|
+
setText(statusText, text);
|
|
406
|
+
setHidden(retry, !(state.status === 'error' && problem?.kind === 'other'));
|
|
315
407
|
statusText.classList.toggle('fd-status-error', state.status === 'error');
|
|
316
408
|
statusText.classList.toggle('fd-status-saved', state.status === 'saved');
|
|
317
409
|
// A toast shows while there is something to say, and lets "Saved" go after a moment.
|
|
@@ -356,7 +448,7 @@ export function mountViewer(host, options) {
|
|
|
356
448
|
discardDraft.addEventListener('click', () => form.discardDraft());
|
|
357
449
|
draft.append(draftText, restore, discardDraft);
|
|
358
450
|
updaters.push((state) => {
|
|
359
|
-
draft
|
|
451
|
+
setHidden(draft, !state.draft);
|
|
360
452
|
if (state.draft)
|
|
361
453
|
draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
|
|
362
454
|
});
|
|
@@ -408,7 +500,7 @@ export function mountViewer(host, options) {
|
|
|
408
500
|
}
|
|
409
501
|
// ---- the four page layouts ---------------------------------------------------
|
|
410
502
|
function sectionsLayout(node) {
|
|
411
|
-
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map(item));
|
|
503
|
+
const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
|
|
412
504
|
const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
|
|
413
505
|
const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
|
|
414
506
|
if (page.data.kind === 'record') {
|
|
@@ -418,12 +510,12 @@ export function mountViewer(host, options) {
|
|
|
418
510
|
actions.insertBefore(discard, action);
|
|
419
511
|
updaters.push((state) => {
|
|
420
512
|
const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
|
|
421
|
-
action
|
|
422
|
-
discard
|
|
513
|
+
setHidden(action, !dirty);
|
|
514
|
+
setHidden(discard, !dirty);
|
|
423
515
|
});
|
|
424
516
|
}
|
|
425
517
|
else
|
|
426
|
-
updaters.push(() => (action
|
|
518
|
+
updaters.push(() => setHidden(action, locked));
|
|
427
519
|
if (editSwitch)
|
|
428
520
|
actions.prepend(editSwitch);
|
|
429
521
|
if (options.showActions === false)
|
|
@@ -461,7 +553,8 @@ export function mountViewer(host, options) {
|
|
|
461
553
|
panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, ...withIcon(step.icon, step.label)));
|
|
462
554
|
if (step.description)
|
|
463
555
|
panel.append(el('p', { class: 'fd-section-description' }, step.description));
|
|
464
|
-
|
|
556
|
+
// The step is the box: a card in it draws none of its own.
|
|
557
|
+
panel.append(grid(step.children, 1, top(false)));
|
|
465
558
|
box.append(panel);
|
|
466
559
|
return { step, panel };
|
|
467
560
|
});
|
|
@@ -604,12 +697,12 @@ export function mountViewer(host, options) {
|
|
|
604
697
|
if (node.title) {
|
|
605
698
|
const title = el('div', { class: 'fd-title' });
|
|
606
699
|
if (node.title.above?.length)
|
|
607
|
-
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map(fieldItem)));
|
|
700
|
+
title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
|
|
608
701
|
title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
|
|
609
702
|
if (node.title.subtitleField)
|
|
610
703
|
title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
|
|
611
704
|
if (node.title.below?.length)
|
|
612
|
-
title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map(fieldItem)));
|
|
705
|
+
title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map((part) => fieldItem(part))));
|
|
613
706
|
const row = el('div', { class: 'fd-title-row' }, title);
|
|
614
707
|
if (node.title.avatarField) {
|
|
615
708
|
const avatar = fieldItem({ type: 'field', id: '#avatar', field: node.title.avatarField });
|
|
@@ -620,7 +713,7 @@ export function mountViewer(host, options) {
|
|
|
620
713
|
}
|
|
621
714
|
if (node.statusbar && underTitle)
|
|
622
715
|
card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
|
|
623
|
-
card.append(grid(node.children));
|
|
716
|
+
card.append(grid(node.children, 1, top(false)));
|
|
624
717
|
if (atFoot && options.showActions !== false) {
|
|
625
718
|
card.append(foot);
|
|
626
719
|
updaters.push((state) => {
|
|
@@ -763,8 +856,14 @@ export function mountViewer(host, options) {
|
|
|
763
856
|
const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
|
|
764
857
|
const dialog = el('div', { class: 'fd-dialog', role: 'alertdialog', 'aria-modal': 'true', 'aria-labelledby': text.id }, text, el('div', { class: 'fd-actions fd-actions-end' }, ...(withCancel ? [cancel, ok] : [ok])));
|
|
765
858
|
const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
|
|
859
|
+
const doc = root.ownerDocument;
|
|
860
|
+
const opener = doc.activeElement;
|
|
766
861
|
const close = (answer) => {
|
|
767
862
|
backdrop.remove();
|
|
863
|
+
// Back to what had focus when it asked; or, when that let go of it — Save hides while it saves — to Save, shown again.
|
|
864
|
+
const shown = (e) => !!e && e !== doc.body && e.isConnected && !e.closest('[hidden]');
|
|
865
|
+
const back = shown(opener) ? opener : root.querySelector('button[type="submit"]:not([hidden])');
|
|
866
|
+
back?.focus();
|
|
768
867
|
resolve(answer);
|
|
769
868
|
};
|
|
770
869
|
cancel.addEventListener('click', () => close(false));
|
|
@@ -772,6 +871,8 @@ export function mountViewer(host, options) {
|
|
|
772
871
|
backdrop.addEventListener('keydown', (event) => {
|
|
773
872
|
if (event.key === 'Escape')
|
|
774
873
|
close(false);
|
|
874
|
+
else
|
|
875
|
+
keepTabIn(dialog, event);
|
|
775
876
|
});
|
|
776
877
|
root.append(backdrop);
|
|
777
878
|
ok.focus();
|
|
@@ -821,6 +922,8 @@ export function mountViewer(host, options) {
|
|
|
821
922
|
},
|
|
822
923
|
};
|
|
823
924
|
}
|
|
925
|
+
/** The boxes a label can be shown in, as the words of an empty box. */
|
|
926
|
+
const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
|
|
824
927
|
/** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
|
|
825
928
|
const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
|
|
826
929
|
//# sourceMappingURL=viewer.mjs.map
|