@fieldia/viewer 0.6.0 → 0.7.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.js +33 -61
- package/bundle/fieldia.js.map +4 -4
- package/package.json +3 -3
- package/src/lib/dialog.js +10 -0
- package/src/lib/dialog.js.map +1 -1
- package/src/lib/dialog.mjs +10 -0
- package/src/lib/dialog.mjs.map +1 -1
- package/src/lib/labels.d.mts +43 -0
- package/src/lib/labels.d.ts +43 -0
- package/src/lib/labels.js +128 -0
- package/src/lib/labels.js.map +1 -0
- package/src/lib/labels.mjs +125 -0
- package/src/lib/labels.mjs.map +1 -0
- package/src/lib/related.d.mts +7 -0
- package/src/lib/related.d.ts +7 -0
- package/src/lib/related.js +88 -0
- package/src/lib/related.js.map +1 -0
- package/src/lib/related.mjs +84 -0
- package/src/lib/related.mjs.map +1 -0
- package/src/lib/viewer.d.mts +27 -31
- package/src/lib/viewer.d.ts +27 -31
- package/src/lib/viewer.js +207 -197
- package/src/lib/viewer.js.map +1 -1
- package/src/lib/viewer.mjs +204 -196
- package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.js
CHANGED
|
@@ -4,103 +4,11 @@ exports.DEFAULT_LABELS = exports.VIEWER_LABELS = void 0;
|
|
|
4
4
|
exports.mountViewer = mountViewer;
|
|
5
5
|
const core_1 = require("@fieldia/core");
|
|
6
6
|
const widgets_1 = require("@fieldia/widgets");
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
saving: 'Saving…',
|
|
13
|
-
saved: 'Saved',
|
|
14
|
-
submit: 'Submit',
|
|
15
|
-
next: 'Next',
|
|
16
|
-
back: 'Back',
|
|
17
|
-
skip: 'Skip',
|
|
18
|
-
steps: 'Steps',
|
|
19
|
-
stepOf: 'Step {n} of {total}',
|
|
20
|
-
submitted: 'Thank you. Your answers were sent.',
|
|
21
|
-
submitAnother: 'Submit another response',
|
|
22
|
-
draftFound: 'You have unsaved answers from {time}.',
|
|
23
|
-
restore: 'Restore',
|
|
24
|
-
discardDraft: 'Discard',
|
|
25
|
-
ok: 'OK',
|
|
26
|
-
cancel: 'Cancel',
|
|
27
|
-
loading: 'Loading…',
|
|
28
|
-
saveClose: 'Save & Close',
|
|
29
|
-
close: 'Close',
|
|
30
|
-
dismiss: 'Dismiss',
|
|
31
|
-
},
|
|
32
|
-
ar: {
|
|
33
|
-
save: 'حفظ',
|
|
34
|
-
discard: 'تجاهل',
|
|
35
|
-
saving: 'جارٍ الحفظ…',
|
|
36
|
-
saved: 'تم الحفظ',
|
|
37
|
-
submit: 'إرسال',
|
|
38
|
-
next: 'التالي',
|
|
39
|
-
back: 'رجوع',
|
|
40
|
-
skip: 'تخطٍّ',
|
|
41
|
-
steps: 'الخطوات',
|
|
42
|
-
stepOf: 'الخطوة {n} من {total}',
|
|
43
|
-
submitted: 'شكرًا لك. تم إرسال إجاباتك.',
|
|
44
|
-
submitAnother: 'إرسال إجابة أخرى',
|
|
45
|
-
draftFound: 'لديك إجابات غير محفوظة من {time}.',
|
|
46
|
-
restore: 'استعادة',
|
|
47
|
-
discardDraft: 'تجاهل',
|
|
48
|
-
ok: 'موافق',
|
|
49
|
-
cancel: 'إلغاء',
|
|
50
|
-
loading: 'جارٍ التحميل…',
|
|
51
|
-
saveClose: 'حفظ وإغلاق',
|
|
52
|
-
close: 'إغلاق',
|
|
53
|
-
dismiss: 'إخفاء',
|
|
54
|
-
},
|
|
55
|
-
de: {
|
|
56
|
-
save: 'Speichern',
|
|
57
|
-
discard: 'Verwerfen',
|
|
58
|
-
saving: 'Wird gespeichert…',
|
|
59
|
-
saved: 'Gespeichert',
|
|
60
|
-
submit: 'Absenden',
|
|
61
|
-
next: 'Weiter',
|
|
62
|
-
back: 'Zurück',
|
|
63
|
-
skip: 'Überspringen',
|
|
64
|
-
steps: 'Schritte',
|
|
65
|
-
stepOf: 'Schritt {n} von {total}',
|
|
66
|
-
submitted: 'Vielen Dank. Ihre Antworten wurden gesendet.',
|
|
67
|
-
submitAnother: 'Weitere Antwort senden',
|
|
68
|
-
draftFound: 'Sie haben ungespeicherte Antworten vom {time}.',
|
|
69
|
-
restore: 'Wiederherstellen',
|
|
70
|
-
discardDraft: 'Verwerfen',
|
|
71
|
-
ok: 'OK',
|
|
72
|
-
cancel: 'Abbrechen',
|
|
73
|
-
loading: 'Wird geladen…',
|
|
74
|
-
saveClose: 'Speichern und schließen',
|
|
75
|
-
close: 'Schließen',
|
|
76
|
-
dismiss: 'Ausblenden',
|
|
77
|
-
},
|
|
78
|
-
fr: {
|
|
79
|
-
save: 'Enregistrer',
|
|
80
|
-
discard: 'Annuler les modifications',
|
|
81
|
-
saving: 'Enregistrement…',
|
|
82
|
-
saved: 'Enregistré',
|
|
83
|
-
submit: 'Envoyer',
|
|
84
|
-
next: 'Suivant',
|
|
85
|
-
back: 'Retour',
|
|
86
|
-
skip: 'Passer',
|
|
87
|
-
steps: 'Étapes',
|
|
88
|
-
stepOf: 'Étape {n} sur {total}',
|
|
89
|
-
submitted: 'Merci. Vos réponses ont été envoyées.',
|
|
90
|
-
submitAnother: 'Envoyer une autre réponse',
|
|
91
|
-
draftFound: 'Vous avez des réponses non enregistrées du {time}.',
|
|
92
|
-
restore: 'Restaurer',
|
|
93
|
-
discardDraft: 'Ignorer',
|
|
94
|
-
ok: 'OK',
|
|
95
|
-
cancel: 'Annuler',
|
|
96
|
-
loading: 'Chargement…',
|
|
97
|
-
saveClose: 'Enregistrer et fermer',
|
|
98
|
-
close: 'Fermer',
|
|
99
|
-
dismiss: 'Masquer',
|
|
100
|
-
},
|
|
101
|
-
};
|
|
102
|
-
/** English, kept under its old name. */
|
|
103
|
-
exports.DEFAULT_LABELS = exports.VIEWER_LABELS.en;
|
|
7
|
+
const related_1 = require("./related");
|
|
8
|
+
const labels_1 = require("./labels");
|
|
9
|
+
var labels_2 = require("./labels");
|
|
10
|
+
Object.defineProperty(exports, "VIEWER_LABELS", { enumerable: true, get: function () { return labels_2.VIEWER_LABELS; } });
|
|
11
|
+
Object.defineProperty(exports, "DEFAULT_LABELS", { enumerable: true, get: function () { return labels_2.DEFAULT_LABELS; } });
|
|
104
12
|
let mounts = 0;
|
|
105
13
|
/**
|
|
106
14
|
* Render a page into `host` and keep it in step with its form. Plain DOM: the
|
|
@@ -109,17 +17,18 @@ let mounts = 0;
|
|
|
109
17
|
*/
|
|
110
18
|
function mountViewer(host, options) {
|
|
111
19
|
const doc = host.ownerDocument;
|
|
112
|
-
|
|
20
|
+
// The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
|
|
21
|
+
const checked = (0, core_1.checkPage)(options.page);
|
|
113
22
|
if (!checked.ok) {
|
|
114
23
|
throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
|
|
115
24
|
}
|
|
116
|
-
const page = checked.page;
|
|
25
|
+
const page = options.translate ? (0, core_1.translatePage)(checked.page, options.translate) : checked.page;
|
|
117
26
|
const locale = options.locale ?? 'en';
|
|
118
27
|
const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
|
|
119
|
-
const dialogs = pageDialogs(options);
|
|
28
|
+
const dialogs = (0, related_1.pageDialogs)(options);
|
|
120
29
|
const ownsForm = !options.form;
|
|
121
30
|
const form = options.form ?? (0, core_1.createForm)({ ...options, page, messages: options.messages ?? core_1.MESSAGES[locale] });
|
|
122
|
-
const labels = { ...
|
|
31
|
+
const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
|
|
123
32
|
const widgetLabels = widgets_1.WIDGET_LABELS[locale];
|
|
124
33
|
const dir = options.dir ?? (locale === 'ar' ? 'rtl' : undefined);
|
|
125
34
|
const prefix = `fd${++mounts}`;
|
|
@@ -151,6 +60,12 @@ function mountViewer(host, options) {
|
|
|
151
60
|
if (node.colspan)
|
|
152
61
|
wrapper.style.setProperty('--fd-span', String(node.colspan));
|
|
153
62
|
const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, node.label ?? def.label);
|
|
63
|
+
// The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
|
|
64
|
+
const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
|
|
65
|
+
if (mark) {
|
|
66
|
+
mark.classList.add('fd-valid-mark');
|
|
67
|
+
label.append(mark);
|
|
68
|
+
}
|
|
154
69
|
const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
155
70
|
if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
|
|
156
71
|
// `for` stays: a custom field that puts the id on its own input is
|
|
@@ -162,7 +77,8 @@ function mountViewer(host, options) {
|
|
|
162
77
|
}
|
|
163
78
|
const helpText = node.help ?? def.help;
|
|
164
79
|
const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
|
|
165
|
-
|
|
80
|
+
// Not an alert of its own: a refused save is announced once, naming every field to look at.
|
|
81
|
+
const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
|
|
166
82
|
// A warning from the data source's onchange, beside the field whose change brought it.
|
|
167
83
|
const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
|
|
168
84
|
wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
|
|
@@ -178,10 +94,17 @@ function mountViewer(host, options) {
|
|
|
178
94
|
const warned = state.warning && state.warningField === node.field ? state.warning : '';
|
|
179
95
|
warning.hidden = !warned;
|
|
180
96
|
warning.textContent = warned;
|
|
97
|
+
if (mark) {
|
|
98
|
+
const value = state.values[node.field];
|
|
99
|
+
const filled = value !== null && value !== undefined && value !== '' && !(Array.isArray(value) && !value.length);
|
|
100
|
+
const valid = !shown.readonly && !message && filled && state.dirty.includes(node.field) && form.problem(node.field) === null;
|
|
101
|
+
wrapper.classList.toggle('fd-valid', valid);
|
|
102
|
+
mark.toggleAttribute('hidden', !valid);
|
|
103
|
+
}
|
|
181
104
|
widget.update({
|
|
182
105
|
value: state.values[node.field],
|
|
183
106
|
values: state.values,
|
|
184
|
-
readonly: shown.readonly,
|
|
107
|
+
readonly: shown.readonly || locked,
|
|
185
108
|
required: shown.required,
|
|
186
109
|
invalid: !!message,
|
|
187
110
|
describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
|
|
@@ -334,12 +257,101 @@ function mountViewer(host, options) {
|
|
|
334
257
|
}
|
|
335
258
|
}
|
|
336
259
|
// ---- shared chrome ----------------------------------------------------------
|
|
337
|
-
|
|
260
|
+
/** The whole form locked: every field read-only, no Save; buttons still run. */
|
|
261
|
+
let locked = options.readonly === true;
|
|
262
|
+
// Edit unlocks; Done saves what changed first, and locks only once that is saved.
|
|
263
|
+
const editSwitch = options.editSwitch ? el('button', { type: 'button', class: 'fd-button fd-edit-switch' }) : null;
|
|
264
|
+
editSwitch?.addEventListener('click', async () => {
|
|
265
|
+
if (!locked) {
|
|
266
|
+
if (form.getState().dirty.length) {
|
|
267
|
+
const saved = await form.save();
|
|
268
|
+
if (!saved) {
|
|
269
|
+
if (!form.getState().saveProblem)
|
|
270
|
+
announce(checkText(form.getState()));
|
|
271
|
+
focusFirstProblem();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
locked = true;
|
|
276
|
+
}
|
|
277
|
+
else
|
|
278
|
+
locked = false;
|
|
279
|
+
root.toggleAttribute('data-readonly', locked);
|
|
280
|
+
render(form.getState());
|
|
281
|
+
});
|
|
282
|
+
root.toggleAttribute('data-readonly', locked);
|
|
283
|
+
updaters.push(() => {
|
|
284
|
+
if (!editSwitch)
|
|
285
|
+
return;
|
|
286
|
+
editSwitch.textContent = locked ? labels.edit : labels.done;
|
|
287
|
+
editSwitch.classList.toggle('fd-button-primary', locked);
|
|
288
|
+
});
|
|
289
|
+
// The status says its words alone, so a screen reader hears them; Retry sits beside it.
|
|
290
|
+
const statusText = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
|
|
291
|
+
const retry = el('button', { type: 'button', class: 'fd-button fd-button-link fd-retry', hidden: '' }, labels.retry);
|
|
292
|
+
retry.addEventListener('click', () => void submitOrSave());
|
|
293
|
+
const status = el('div', { class: 'fd-status-box' }, statusText, retry);
|
|
294
|
+
const notDone = () => (page.data.kind === 'responses' ? labels.notSent : labels.notSaved);
|
|
295
|
+
/** "Not saved. Check: Email, Phone": the fields with a problem, in the order they show. */
|
|
296
|
+
function checkText(state) {
|
|
297
|
+
const names = [];
|
|
298
|
+
for (const key of Object.keys(state.errors)) {
|
|
299
|
+
const field = key.split('.')[0];
|
|
300
|
+
const shown = root.querySelector(`.fd-field[data-field="${field}"]:not([hidden]) > .fd-label`);
|
|
301
|
+
const name = shown?.firstChild?.textContent ?? page.fields[field]?.label ?? field;
|
|
302
|
+
if (!names.includes(name))
|
|
303
|
+
names.push(name);
|
|
304
|
+
}
|
|
305
|
+
const inOrder = [...root.querySelectorAll('.fd-field[data-field] > .fd-label')].map((label) => label.firstChild?.textContent ?? '');
|
|
306
|
+
names.sort((a, b) => (inOrder.indexOf(a) + 1 || Infinity) - (inOrder.indexOf(b) + 1 || Infinity));
|
|
307
|
+
return names.length ? fill(labels.checkFields, { what: notDone(), fields: names.join(', ') }) : notDone();
|
|
308
|
+
}
|
|
309
|
+
let quietTimer;
|
|
338
310
|
updaters.push((state) => {
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
311
|
+
const problem = state.saveProblem;
|
|
312
|
+
const text = state.status === 'saving' ? labels.saving
|
|
313
|
+
: state.status === 'saved' ? labels.saved
|
|
314
|
+
: state.status === 'loading' ? labels.loading
|
|
315
|
+
: state.status === 'error' && problem ? (problem.kind === 'fields' ? checkText(state) : problem.kind === 'other' ? problem.message : notDone())
|
|
316
|
+
: state.status === 'error' ? state.error ?? ''
|
|
317
|
+
: '';
|
|
318
|
+
statusText.textContent = text;
|
|
319
|
+
retry.hidden = !(state.status === 'error' && problem?.kind === 'other');
|
|
320
|
+
statusText.classList.toggle('fd-status-error', state.status === 'error');
|
|
321
|
+
statusText.classList.toggle('fd-status-saved', state.status === 'saved');
|
|
322
|
+
// A toast shows while there is something to say, and lets "Saved" go after a moment.
|
|
323
|
+
if (options.saveStatus === 'toast') {
|
|
324
|
+
clearTimeout(quietTimer);
|
|
325
|
+
status.hidden = !text;
|
|
326
|
+
if (state.status === 'saved')
|
|
327
|
+
quietTimer = setTimeout(() => (status.hidden = true), 2500);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
// A save the network failed: a banner over the page, with Retry.
|
|
331
|
+
const bannerRetry = el('button', { type: 'button', class: 'fd-button fd-retry' }, labels.retry);
|
|
332
|
+
bannerRetry.addEventListener('click', () => void submitOrSave());
|
|
333
|
+
const banner = el('div', { class: 'fd-banner fd-tone-warning', role: 'alert', hidden: '' }, el('span', { class: 'fd-banner-text' }, labels.offline), bannerRetry);
|
|
334
|
+
/** Said at once to a screen reader: why a save or send did not go through. */
|
|
335
|
+
const announcer = el('div', { class: 'fd-announce', role: 'alert', 'aria-live': 'assertive' });
|
|
336
|
+
function announce(text) {
|
|
337
|
+
announcer.textContent = '';
|
|
338
|
+
setTimeout(() => (announcer.textContent = text), 0);
|
|
339
|
+
}
|
|
340
|
+
// Each new reason a save was refused, from Save or from autosave, is shown where it belongs.
|
|
341
|
+
let seenProblem = null;
|
|
342
|
+
updaters.push((state) => {
|
|
343
|
+
if (state.saveProblem === seenProblem)
|
|
344
|
+
return;
|
|
345
|
+
seenProblem = state.saveProblem;
|
|
346
|
+
banner.hidden = seenProblem?.kind !== 'network';
|
|
347
|
+
if (!seenProblem)
|
|
348
|
+
return;
|
|
349
|
+
if (seenProblem.kind === 'rule')
|
|
350
|
+
void dialogConfirm(seenProblem.message, false);
|
|
351
|
+
else if (seenProblem.kind === 'fields')
|
|
352
|
+
announce(checkText(state));
|
|
353
|
+
else if (seenProblem.kind === 'other')
|
|
354
|
+
announce(seenProblem.message);
|
|
343
355
|
});
|
|
344
356
|
const draft = el('div', { class: 'fd-draft', hidden: '' });
|
|
345
357
|
const draftText = el('span');
|
|
@@ -373,6 +385,9 @@ function mountViewer(host, options) {
|
|
|
373
385
|
async function submitOrSave() {
|
|
374
386
|
const saved = await form.save();
|
|
375
387
|
if (!saved) {
|
|
388
|
+
// Stopped by the page's own checks: say which fields; a refused save says so above.
|
|
389
|
+
if (!form.getState().saveProblem)
|
|
390
|
+
announce(checkText(form.getState()));
|
|
376
391
|
focusFirstProblem();
|
|
377
392
|
return;
|
|
378
393
|
}
|
|
@@ -407,11 +422,15 @@ function mountViewer(host, options) {
|
|
|
407
422
|
discard.addEventListener('click', () => form.reset());
|
|
408
423
|
actions.insertBefore(discard, action);
|
|
409
424
|
updaters.push((state) => {
|
|
410
|
-
const dirty = state.dirty.length > 0 && state.status !== 'saving';
|
|
425
|
+
const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
|
|
411
426
|
action.hidden = !dirty;
|
|
412
427
|
discard.hidden = !dirty;
|
|
413
428
|
});
|
|
414
429
|
}
|
|
430
|
+
else
|
|
431
|
+
updaters.push(() => (action.hidden = locked));
|
|
432
|
+
if (editSwitch)
|
|
433
|
+
actions.prepend(editSwitch);
|
|
415
434
|
if (options.showActions === false)
|
|
416
435
|
return el('div', {}, box);
|
|
417
436
|
if (page.actionsPosition === 'top') {
|
|
@@ -520,8 +539,11 @@ function mountViewer(host, options) {
|
|
|
520
539
|
}
|
|
521
540
|
else
|
|
522
541
|
actions.append(save, discard, ...(node.buttons ?? []).map(buttonItem), status);
|
|
542
|
+
// The Edit switch leads the header bar, before Save and the record's own buttons.
|
|
543
|
+
if (editSwitch && options.showActions !== false)
|
|
544
|
+
actions.prepend(editSwitch);
|
|
523
545
|
updaters.push((state) => {
|
|
524
|
-
const dirty = state.dirty.length > 0 && state.status !== 'saving';
|
|
546
|
+
const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
|
|
525
547
|
save.hidden = !dirty;
|
|
526
548
|
discard.hidden = !dirty;
|
|
527
549
|
});
|
|
@@ -607,7 +629,7 @@ function mountViewer(host, options) {
|
|
|
607
629
|
if (atFoot && options.showActions !== false) {
|
|
608
630
|
card.append(foot);
|
|
609
631
|
updaters.push((state) => {
|
|
610
|
-
foot.hidden = state.dirty.length === 0 && !
|
|
632
|
+
foot.hidden = state.dirty.length === 0 && !statusText.textContent;
|
|
611
633
|
});
|
|
612
634
|
}
|
|
613
635
|
const layout = el('div', { class: 'fd-sheet-layout' }, card);
|
|
@@ -626,7 +648,7 @@ function mountViewer(host, options) {
|
|
|
626
648
|
barOptions['visibleStates'] = config.visibleStates;
|
|
627
649
|
const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
|
|
628
650
|
const widget = (0, widgets_1.createWidget)({ form, name: config.field, field: def, node, id: uid('statusbar'), document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
|
|
629
|
-
updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly, required: false, invalid: false }));
|
|
651
|
+
updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false }));
|
|
630
652
|
return widget.element;
|
|
631
653
|
}
|
|
632
654
|
let wizardForward = null;
|
|
@@ -641,8 +663,17 @@ function mountViewer(host, options) {
|
|
|
641
663
|
else
|
|
642
664
|
body = sectionsLayout(layout);
|
|
643
665
|
const head = layout.type === 'sheet' ? null : pageHead();
|
|
644
|
-
content.append(...(head ? [head] : []), draft, body);
|
|
645
|
-
root.append(content, done);
|
|
666
|
+
content.append(...(head ? [head] : []), banner, draft, body);
|
|
667
|
+
root.append(content, done, announcer);
|
|
668
|
+
// The status leaves its place beside Save when the page wants it in a corner or across the top.
|
|
669
|
+
if (options.saveStatus === 'toast') {
|
|
670
|
+
status.classList.add('fd-toast');
|
|
671
|
+
root.append(status);
|
|
672
|
+
}
|
|
673
|
+
else if (options.saveStatus === 'bar') {
|
|
674
|
+
status.classList.add('fd-status-bar');
|
|
675
|
+
content.prepend(status);
|
|
676
|
+
}
|
|
646
677
|
root.addEventListener('submit', (event) => {
|
|
647
678
|
event.preventDefault();
|
|
648
679
|
if (wizardForward)
|
|
@@ -650,13 +681,65 @@ function mountViewer(host, options) {
|
|
|
650
681
|
else
|
|
651
682
|
void submitOrSave();
|
|
652
683
|
});
|
|
684
|
+
// ---- keys -------------------------------------------------------------------
|
|
685
|
+
/** Text typed and not yet taken, such as a tag without its Enter, is taken now. */
|
|
686
|
+
function commitTyping() {
|
|
687
|
+
const active = doc.activeElement;
|
|
688
|
+
if (active && root.contains(active) && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA')) {
|
|
689
|
+
active.dispatchEvent(new Event('change', { bubbles: true }));
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** The fields Enter can move between: on this page, shown, with a box to type or choose in, outside tables of lines. */
|
|
693
|
+
function nextField(from) {
|
|
694
|
+
const fields = [...root.querySelectorAll('.fd-field')].filter((field) => !field.closest('[hidden], [data-type="one2many"] .fd-field, .fd-form-dialog') && !field.parentElement?.closest('[data-type="one2many"]'));
|
|
695
|
+
const here = from.closest('.fd-field');
|
|
696
|
+
const after = fields.slice(fields.indexOf(here) + 1);
|
|
697
|
+
for (const field of after) {
|
|
698
|
+
const control = field.querySelector('input:not([type="hidden"]):not([readonly]):not([disabled]), select:not([disabled]), textarea:not([readonly]), [contenteditable="true"]');
|
|
699
|
+
if (control)
|
|
700
|
+
return control;
|
|
701
|
+
}
|
|
702
|
+
return null;
|
|
703
|
+
}
|
|
704
|
+
root.addEventListener('keydown', (event) => {
|
|
705
|
+
if (event.isComposing || event.key !== 'Enter')
|
|
706
|
+
return;
|
|
707
|
+
const keys = options.keys ?? {};
|
|
708
|
+
if (event.ctrlKey || event.metaKey) {
|
|
709
|
+
// Saves even after a field used the Enter (a tag box adding its tag); the grid keeps its own keys.
|
|
710
|
+
if (keys.saveWithCtrlEnter === false || options.showActions === false)
|
|
711
|
+
return;
|
|
712
|
+
if (event.target.closest('.ag-root-wrapper'))
|
|
713
|
+
return;
|
|
714
|
+
event.preventDefault();
|
|
715
|
+
commitTyping();
|
|
716
|
+
if (wizardForward)
|
|
717
|
+
void wizardForward();
|
|
718
|
+
else
|
|
719
|
+
void submitOrSave();
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
// A plain Enter a field used itself, such as picking from an open list, is the field's.
|
|
723
|
+
if (event.defaultPrevented || !keys.enterMovesToNext || event.shiftKey || event.altKey)
|
|
724
|
+
return;
|
|
725
|
+
const from = event.target;
|
|
726
|
+
if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT')
|
|
727
|
+
return;
|
|
728
|
+
if (from.closest('[data-type="one2many"]'))
|
|
729
|
+
return;
|
|
730
|
+
if (['button', 'submit', 'reset', 'file'].includes(from.type))
|
|
731
|
+
return;
|
|
732
|
+
event.preventDefault();
|
|
733
|
+
nextField(from)?.focus();
|
|
734
|
+
});
|
|
653
735
|
// ---- confirmation dialog -----------------------------------------------------
|
|
654
|
-
|
|
736
|
+
/** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
|
|
737
|
+
function dialogConfirm(message, withCancel = true) {
|
|
655
738
|
return new Promise((resolve) => {
|
|
656
739
|
const text = el('p', { id: `${prefix}-confirm` }, message);
|
|
657
740
|
const cancel = el('button', { type: 'button', class: 'fd-button' }, labels.cancel);
|
|
658
741
|
const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
|
|
659
|
-
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' }, cancel, ok));
|
|
742
|
+
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])));
|
|
660
743
|
const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
|
|
661
744
|
const close = (answer) => {
|
|
662
745
|
backdrop.remove();
|
|
@@ -700,6 +783,12 @@ function mountViewer(host, options) {
|
|
|
700
783
|
focusFirstProblem();
|
|
701
784
|
return valid;
|
|
702
785
|
},
|
|
786
|
+
setReadonly(readonly) {
|
|
787
|
+
locked = readonly;
|
|
788
|
+
root.toggleAttribute('data-readonly', locked);
|
|
789
|
+
render(form.getState());
|
|
790
|
+
},
|
|
791
|
+
isReadonly: () => locked,
|
|
703
792
|
destroy() {
|
|
704
793
|
unsubscribe();
|
|
705
794
|
for (const cleanup of cleanups)
|
|
@@ -710,85 +799,6 @@ function mountViewer(host, options) {
|
|
|
710
799
|
},
|
|
711
800
|
};
|
|
712
801
|
}
|
|
713
|
-
/**
|
|
714
|
-
|
|
715
|
-
if (page.layout.type === 'sheet' && page.layout.title?.field)
|
|
716
|
-
return page.layout.title.field;
|
|
717
|
-
if (page.fields['name'])
|
|
718
|
-
return 'name';
|
|
719
|
-
return Object.entries(page.fields).find(([, def]) => def.type === 'char')?.[0] ?? null;
|
|
720
|
-
}
|
|
721
|
-
/** A form made of fields, for values edited in a dialog (a line, for one). */
|
|
722
|
-
function valuesPage(fields, title, readonly) {
|
|
723
|
-
const shown = Object.fromEntries(Object.entries(fields).map(([name, def]) => [name, readonly ? { ...def, readonly: true } : def]));
|
|
724
|
-
return {
|
|
725
|
-
fieldia: '0.1',
|
|
726
|
-
id: 'values',
|
|
727
|
-
title,
|
|
728
|
-
data: { kind: 'record', model: 'values' },
|
|
729
|
-
fields: shown,
|
|
730
|
-
layout: {
|
|
731
|
-
type: 'sections',
|
|
732
|
-
id: 'values',
|
|
733
|
-
children: [{ type: 'section', id: 'values-section', columns: 2, children: Object.keys(shown).map((name) => ({ type: 'field', id: `values-${name}`, field: name })) }],
|
|
734
|
-
},
|
|
735
|
-
};
|
|
736
|
-
}
|
|
737
|
-
/** Only finding and making linked records: values edited in a dialog are no record of the source's to load, save or recalculate. */
|
|
738
|
-
function lookupsOf(source) {
|
|
739
|
-
if (!source)
|
|
740
|
-
return undefined;
|
|
741
|
-
return {
|
|
742
|
-
...(source.search ? { search: (request) => source.search(request) } : {}),
|
|
743
|
-
...(source.create ? { create: (request) => source.create(request) } : {}),
|
|
744
|
-
};
|
|
745
|
-
}
|
|
746
|
-
/** The dialogs a page's widgets may open, made from the viewer's own options. */
|
|
747
|
-
function pageDialogs(options) {
|
|
748
|
-
const pageFor = (model) => (typeof options.relatedPages === 'function' ? options.relatedPages(model) : options.relatedPages?.[model]) ?? null;
|
|
749
|
-
// A dialog's page looks and reads like the page that opened it, and can open dialogs of its own.
|
|
750
|
-
const shared = {
|
|
751
|
-
locale: options.locale,
|
|
752
|
-
skin: options.skin,
|
|
753
|
-
dir: options.dir,
|
|
754
|
-
widgets: options.widgets,
|
|
755
|
-
preferences: options.preferences,
|
|
756
|
-
relatedPages: options.relatedPages,
|
|
757
|
-
};
|
|
758
|
-
return {
|
|
759
|
-
canOpen: (model) => pageFor(model) !== null,
|
|
760
|
-
async openRecord(model, request) {
|
|
761
|
-
const related = pageFor(model);
|
|
762
|
-
if (!related)
|
|
763
|
-
return null;
|
|
764
|
-
const nameField = nameFieldOf(related);
|
|
765
|
-
const result = await (0, dialog_1.openFormDialog)({
|
|
766
|
-
...shared,
|
|
767
|
-
page: related,
|
|
768
|
-
dataSource: options.dataSource,
|
|
769
|
-
recordId: request.recordId ?? null,
|
|
770
|
-
values: request.name && nameField ? { [nameField]: request.name } : undefined,
|
|
771
|
-
title: request.title,
|
|
772
|
-
size: 'large',
|
|
773
|
-
});
|
|
774
|
-
if (!result.saved || result.recordId === null)
|
|
775
|
-
return null;
|
|
776
|
-
const name = nameField ? result.values[nameField] : null;
|
|
777
|
-
return { id: result.recordId, label: typeof name === 'string' && name ? name : request.name ?? request.title };
|
|
778
|
-
},
|
|
779
|
-
searchMore: (request) => (0, dialog_1.openSearchDialog)({ title: request.title, search: request.search, locale: options.locale, skin: options.skin, dir: options.dir }),
|
|
780
|
-
async editValues(request) {
|
|
781
|
-
const result = await (0, dialog_1.openFormDialog)({
|
|
782
|
-
...shared,
|
|
783
|
-
page: valuesPage(request.fields, request.title, request.readonly === true),
|
|
784
|
-
dataSource: lookupsOf(options.dataSource),
|
|
785
|
-
values: request.values,
|
|
786
|
-
title: request.title,
|
|
787
|
-
mode: 'values',
|
|
788
|
-
...(request.recompute ? { recompute: request.recompute } : {}),
|
|
789
|
-
});
|
|
790
|
-
return result.saved ? result.values : null;
|
|
791
|
-
},
|
|
792
|
-
};
|
|
793
|
-
}
|
|
802
|
+
/** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
|
|
803
|
+
const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
|
|
794
804
|
//# sourceMappingURL=viewer.js.map
|