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