@fieldia/viewer 0.5.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/src/lib/viewer.js CHANGED
@@ -4,91 +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 dialog_1 = require("./dialog");
8
- exports.VIEWER_LABELS = {
9
- en: {
10
- save: 'Save',
11
- discard: 'Discard',
12
- saving: 'Saving…',
13
- saved: 'Saved',
14
- submit: 'Submit',
15
- next: 'Next',
16
- back: 'Back',
17
- stepOf: 'Step {n} of {total}',
18
- submitted: 'Thank you. Your answers were sent.',
19
- submitAnother: 'Submit another response',
20
- draftFound: 'You have unsaved answers from {time}.',
21
- restore: 'Restore',
22
- discardDraft: 'Discard',
23
- ok: 'OK',
24
- cancel: 'Cancel',
25
- loading: 'Loading…',
26
- saveClose: 'Save & Close',
27
- close: 'Close',
28
- },
29
- ar: {
30
- save: 'حفظ',
31
- discard: 'تجاهل',
32
- saving: 'جارٍ الحفظ…',
33
- saved: 'تم الحفظ',
34
- submit: 'إرسال',
35
- next: 'التالي',
36
- back: 'رجوع',
37
- stepOf: 'الخطوة {n} من {total}',
38
- submitted: 'شكرًا لك. تم إرسال إجاباتك.',
39
- submitAnother: 'إرسال إجابة أخرى',
40
- draftFound: 'لديك إجابات غير محفوظة من {time}.',
41
- restore: 'استعادة',
42
- discardDraft: 'تجاهل',
43
- ok: 'موافق',
44
- cancel: 'إلغاء',
45
- loading: 'جارٍ التحميل…',
46
- saveClose: 'حفظ وإغلاق',
47
- close: 'إغلاق',
48
- },
49
- de: {
50
- save: 'Speichern',
51
- discard: 'Verwerfen',
52
- saving: 'Wird gespeichert…',
53
- saved: 'Gespeichert',
54
- submit: 'Absenden',
55
- next: 'Weiter',
56
- back: 'Zurück',
57
- stepOf: 'Schritt {n} von {total}',
58
- submitted: 'Vielen Dank. Ihre Antworten wurden gesendet.',
59
- submitAnother: 'Weitere Antwort senden',
60
- draftFound: 'Sie haben ungespeicherte Antworten vom {time}.',
61
- restore: 'Wiederherstellen',
62
- discardDraft: 'Verwerfen',
63
- ok: 'OK',
64
- cancel: 'Abbrechen',
65
- loading: 'Wird geladen…',
66
- saveClose: 'Speichern und schließen',
67
- close: 'Schließen',
68
- },
69
- fr: {
70
- save: 'Enregistrer',
71
- discard: 'Annuler les modifications',
72
- saving: 'Enregistrement…',
73
- saved: 'Enregistré',
74
- submit: 'Envoyer',
75
- next: 'Suivant',
76
- back: 'Retour',
77
- stepOf: 'Étape {n} sur {total}',
78
- submitted: 'Merci. Vos réponses ont été envoyées.',
79
- submitAnother: 'Envoyer une autre réponse',
80
- draftFound: 'Vous avez des réponses non enregistrées du {time}.',
81
- restore: 'Restaurer',
82
- discardDraft: 'Ignorer',
83
- ok: 'OK',
84
- cancel: 'Annuler',
85
- loading: 'Chargement…',
86
- saveClose: 'Enregistrer et fermer',
87
- close: 'Fermer',
88
- },
89
- };
90
- /** English, kept under its old name. */
91
- 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; } });
92
12
  let mounts = 0;
93
13
  /**
94
14
  * Render a page into `host` and keep it in step with its form. Plain DOM: the
@@ -97,17 +17,18 @@ let mounts = 0;
97
17
  */
98
18
  function mountViewer(host, options) {
99
19
  const doc = host.ownerDocument;
100
- const checked = (0, core_1.validatePage)(options.page);
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);
101
22
  if (!checked.ok) {
102
23
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
103
24
  }
104
- const page = checked.page;
25
+ const page = options.translate ? (0, core_1.translatePage)(checked.page, options.translate) : checked.page;
105
26
  const locale = options.locale ?? 'en';
106
27
  const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
107
- const dialogs = pageDialogs(options);
28
+ const dialogs = (0, related_1.pageDialogs)(options);
108
29
  const ownsForm = !options.form;
109
30
  const form = options.form ?? (0, core_1.createForm)({ ...options, page, messages: options.messages ?? core_1.MESSAGES[locale] });
110
- const labels = { ...exports.VIEWER_LABELS[locale], ...options.labels };
31
+ const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
111
32
  const widgetLabels = widgets_1.WIDGET_LABELS[locale];
112
33
  const dir = options.dir ?? (locale === 'ar' ? 'rtl' : undefined);
113
34
  const prefix = `fd${++mounts}`;
@@ -124,7 +45,12 @@ function mountViewer(host, options) {
124
45
  node.append(...children);
125
46
  return node;
126
47
  }
127
- const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: options.locale });
48
+ /** Words with the icon a page names before them, when there is such an icon. */
49
+ function withIcon(name, text) {
50
+ const icon = (0, widgets_1.drawIcon)(doc, name, options.icons);
51
+ return icon ? [icon, text] : [text];
52
+ }
53
+ const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: options.locale, 'data-max-width': page.maxWidth });
128
54
  const confirm = options.confirm ?? dialogConfirm;
129
55
  // ---- the parts -------------------------------------------------------
130
56
  function fieldItem(node) {
@@ -134,6 +60,12 @@ function mountViewer(host, options) {
134
60
  if (node.colspan)
135
61
  wrapper.style.setProperty('--fd-span', String(node.colspan));
136
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
+ }
137
69
  const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
138
70
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
139
71
  // `for` stays: a custom field that puts the id on its own input is
@@ -145,7 +77,8 @@ function mountViewer(host, options) {
145
77
  }
146
78
  const helpText = node.help ?? def.help;
147
79
  const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
148
- const error = el('div', { class: 'fd-error', id: `${id}-error`, role: 'alert', hidden: '' });
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: '' });
149
82
  // A warning from the data source's onchange, beside the field whose change brought it.
150
83
  const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
151
84
  wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
@@ -161,10 +94,17 @@ function mountViewer(host, options) {
161
94
  const warned = state.warning && state.warningField === node.field ? state.warning : '';
162
95
  warning.hidden = !warned;
163
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
+ }
164
104
  widget.update({
165
105
  value: state.values[node.field],
166
106
  values: state.values,
167
- readonly: shown.readonly,
107
+ readonly: shown.readonly || locked,
168
108
  required: shown.required,
169
109
  invalid: !!message,
170
110
  describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
@@ -178,7 +118,7 @@ function mountViewer(host, options) {
178
118
  });
179
119
  }
180
120
  function buttonItem(node) {
181
- const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, node.label);
121
+ const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
182
122
  button.addEventListener('click', async () => {
183
123
  if (node.confirm && !(await confirm(node.confirm)))
184
124
  return;
@@ -203,7 +143,17 @@ function mountViewer(host, options) {
203
143
  }
204
144
  function grid(children, columns = 1) {
205
145
  const box = el('div', { class: 'fd-grid' });
206
- box.style.setProperty('--fd-columns', String(columns));
146
+ box.style.setProperty('--fd-columns', String((0, core_1.wideColumns)(columns)));
147
+ // Counts given for the narrower widths replace the skin's own stacking there.
148
+ if (typeof columns === 'object') {
149
+ for (const width of ['medium', 'narrow']) {
150
+ const count = columns[width];
151
+ if (count === undefined)
152
+ continue;
153
+ box.setAttribute(`data-columns-${width}`, String(count));
154
+ box.style.setProperty(`--fd-columns-${width}`, String(count));
155
+ }
156
+ }
207
157
  box.append(...children.map(item));
208
158
  return box;
209
159
  }
@@ -212,10 +162,10 @@ function mountViewer(host, options) {
212
162
  function sectionItem(node) {
213
163
  const section = el('fieldset', { class: 'fd-section', 'data-node': node.id });
214
164
  const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
215
- const content = grid(node.children, node.columns ?? 1);
165
+ const content = grid(node.children, node.columns);
216
166
  if (node.title && node.collapsible) {
217
167
  content.id = uid(`${node.id}-content`);
218
- const toggle = el('button', { type: 'button', class: 'fd-section-toggle', 'aria-controls': content.id }, el('span', { class: 'fd-section-chevron', 'aria-hidden': 'true' }), node.title);
168
+ 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));
219
169
  let open = node.collapsed !== true;
220
170
  const show = (next) => {
221
171
  open = next;
@@ -231,7 +181,7 @@ function mountViewer(host, options) {
231
181
  section.append(el('legend', { class: 'fd-section-title' }, toggle));
232
182
  }
233
183
  else if (node.title) {
234
- section.append(el('legend', { class: 'fd-section-title' }, node.title));
184
+ section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
235
185
  }
236
186
  if (description)
237
187
  section.append(description);
@@ -249,7 +199,7 @@ function mountViewer(host, options) {
249
199
  const parts = node.children.map((tab) => {
250
200
  const tabId = uid(`${tab.id}-tab`);
251
201
  const panelId = uid(`${tab.id}-panel`);
252
- const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, tab.label);
202
+ 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));
253
203
  const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children));
254
204
  button.addEventListener('click', () => {
255
205
  picked = active = tab.id;
@@ -307,12 +257,101 @@ function mountViewer(host, options) {
307
257
  }
308
258
  }
309
259
  // ---- shared chrome ----------------------------------------------------------
310
- const status = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
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;
310
+ updaters.push((state) => {
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;
311
342
  updaters.push((state) => {
312
- const text = state.status === 'saving' ? labels.saving : state.status === 'saved' ? labels.saved : state.status === 'loading' ? labels.loading : state.status === 'error' ? state.error ?? '' : '';
313
- status.textContent = text;
314
- status.classList.toggle('fd-status-error', state.status === 'error');
315
- status.classList.toggle('fd-status-saved', state.status === 'saved');
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);
316
355
  });
317
356
  const draft = el('div', { class: 'fd-draft', hidden: '' });
318
357
  const draftText = el('span');
@@ -346,6 +385,9 @@ function mountViewer(host, options) {
346
385
  async function submitOrSave() {
347
386
  const saved = await form.save();
348
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()));
349
391
  focusFirstProblem();
350
392
  return;
351
393
  }
@@ -380,34 +422,66 @@ function mountViewer(host, options) {
380
422
  discard.addEventListener('click', () => form.reset());
381
423
  actions.insertBefore(discard, action);
382
424
  updaters.push((state) => {
383
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
425
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
384
426
  action.hidden = !dirty;
385
427
  discard.hidden = !dirty;
386
428
  });
387
429
  }
388
- return options.showActions === false ? el('div', {}, box) : el('div', {}, box, actions);
430
+ else
431
+ updaters.push(() => (action.hidden = locked));
432
+ if (editSwitch)
433
+ actions.prepend(editSwitch);
434
+ if (options.showActions === false)
435
+ return el('div', {}, box);
436
+ if (page.actionsPosition === 'top') {
437
+ actions.classList.add('fd-actions-top');
438
+ return el('div', {}, actions, box);
439
+ }
440
+ return el('div', {}, box, actions);
389
441
  }
390
442
  function wizardLayout(node) {
391
443
  const box = el('div', { class: 'fd-wizard', 'data-node': node.id });
392
444
  const progressText = el('div', { class: 'fd-progress-text' });
393
445
  const bar = el('span');
394
446
  box.append(el('div', { class: 'fd-progress' }, progressText, el('div', { class: 'fd-progress-bar' }, bar)));
447
+ // A clickable wizard lists its steps: back to any, forward past those that are complete.
448
+ const list = node.clickable ? el('ol', { class: 'fd-steps-list' }) : null;
449
+ const stepButtons = new Map();
450
+ if (list) {
451
+ for (const step of node.children) {
452
+ const go = el('button', { type: 'button', class: 'fd-step-link' }, ...withIcon(step.icon, step.label));
453
+ go.addEventListener('click', () => {
454
+ if (form.goTo(step.id))
455
+ focusStep();
456
+ else
457
+ focusFirstProblem();
458
+ });
459
+ stepButtons.set(step.id, go);
460
+ list.append(el('li', {}, go));
461
+ }
462
+ box.append(el('nav', { class: 'fd-steps', 'aria-label': labels.steps }, list));
463
+ }
395
464
  const panels = node.children.map((step) => {
396
465
  const panel = el('section', { class: 'fd-step', 'data-node': step.id, 'aria-labelledby': uid(`${step.id}-title`) });
397
- panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, step.label));
466
+ panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, ...withIcon(step.icon, step.label)));
398
467
  if (step.description)
399
468
  panel.append(el('p', { class: 'fd-section-description' }, step.description));
400
469
  panel.append(grid(step.children));
401
470
  box.append(panel);
402
471
  return { step, panel };
403
472
  });
404
- const back = el('button', { type: 'button', class: 'fd-button' }, labels.back);
473
+ const back = el('button', { type: 'button', class: 'fd-button' }, node.backLabel ?? labels.back);
474
+ const skip = el('button', { type: 'button', class: 'fd-button fd-button-link' }, labels.skip);
405
475
  const forward = el('button', { type: 'submit', class: 'fd-button fd-button-primary' });
406
- box.append(el('div', { class: 'fd-wizard-nav' }, back, el('span', { class: 'fd-spacer' }), status, forward));
476
+ box.append(el('div', { class: 'fd-wizard-nav' }, back, el('span', { class: 'fd-spacer' }), status, skip, forward));
407
477
  back.addEventListener('click', () => {
408
478
  if (form.back())
409
479
  focusStep();
410
480
  });
481
+ skip.addEventListener('click', () => {
482
+ if (form.skip())
483
+ focusStep();
484
+ });
411
485
  const isLast = () => {
412
486
  const steps = form.steps();
413
487
  return steps.indexOf(form.getState().step ?? '') === steps.length - 1;
@@ -431,8 +505,20 @@ function mountViewer(host, options) {
431
505
  bar.style.width = `${((index + 1) / Math.max(1, steps.length)) * 100}%`;
432
506
  for (const { step, panel } of panels)
433
507
  panel.hidden = step.id !== state.step;
508
+ const last = index === steps.length - 1;
434
509
  back.hidden = index === 0;
435
- forward.textContent = index === steps.length - 1 ? labels.submit : labels.next;
510
+ skip.hidden = last || !node.children.find((step) => step.id === state.step)?.optional;
511
+ forward.textContent = last ? node.finishLabel ?? labels.submit : node.nextLabel ?? labels.next;
512
+ for (const [id, go] of stepButtons) {
513
+ const at = steps.indexOf(id);
514
+ go.parentElement.hidden = at === -1;
515
+ go.classList.toggle('fd-step-done', at !== -1 && at < index);
516
+ go.classList.toggle('fd-step-skipped', state.skipped.includes(id));
517
+ if (id === state.step)
518
+ go.setAttribute('aria-current', 'step');
519
+ else
520
+ go.removeAttribute('aria-current');
521
+ }
436
522
  });
437
523
  return box;
438
524
  }
@@ -442,17 +528,28 @@ function mountViewer(host, options) {
442
528
  const save = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, labels.save);
443
529
  const discard = el('button', { type: 'button', class: 'fd-button' }, labels.discard);
444
530
  discard.addEventListener('click', () => form.reset());
531
+ // Save and Discard lead the header bar, or close the sheet when the page puts them at its foot.
532
+ const atFoot = page.actionsPosition === 'bottom';
533
+ const foot = el('div', { class: 'fd-actions fd-actions-end fd-sheet-foot' });
445
534
  if (options.showActions === false)
446
535
  actions.append(...(node.buttons ?? []).map(buttonItem));
536
+ else if (atFoot) {
537
+ actions.append(...(node.buttons ?? []).map(buttonItem));
538
+ foot.append(status, discard, save);
539
+ }
447
540
  else
448
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);
449
545
  updaters.push((state) => {
450
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
546
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
451
547
  save.hidden = !dirty;
452
548
  discard.hidden = !dirty;
453
549
  });
454
550
  const header = el('div', { class: 'fd-header' }, actions);
455
- if (node.statusbar)
551
+ const underTitle = node.statusbar?.position === 'title';
552
+ if (node.statusbar && !underTitle)
456
553
  header.append(statusbar(node.statusbar));
457
554
  const card = el('div', { class: 'fd-card' });
458
555
  if (node.ribbon) {
@@ -465,7 +562,9 @@ function mountViewer(host, options) {
465
562
  const stats = el('div', { class: 'fd-stats' });
466
563
  for (const stat of node.statButtons) {
467
564
  const value = el('span', { class: 'fd-stat-value' });
468
- const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, value, el('span', { class: 'fd-stat-label' }, stat.label));
565
+ const icon = (0, widgets_1.drawIcon)(doc, stat.icon, options.icons);
566
+ const words = el('span', { class: 'fd-stat-words' }, value, el('span', { class: 'fd-stat-label' }, stat.label));
567
+ const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, ...(icon ? [icon, words] : [words]));
469
568
  button.addEventListener('click', () => void form.runAction(stat.id));
470
569
  updaters.push((state) => {
471
570
  button.hidden = form.node(stat.id).invisible;
@@ -477,15 +576,45 @@ function mountViewer(host, options) {
477
576
  card.append(stats);
478
577
  }
479
578
  for (const alert of node.alerts ?? []) {
480
- const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, alert.message);
481
- hideWhen(box, alert.id);
579
+ const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, el('span', { class: 'fd-alert-message' }, alert.message));
580
+ if (alert.dismissible) {
581
+ // Closed, it stays closed while the page is open, whatever its condition does.
582
+ let dismissed = false;
583
+ const close = el('button', { type: 'button', class: 'fd-alert-close', 'aria-label': labels.dismiss }, '×');
584
+ close.addEventListener('click', () => {
585
+ dismissed = true;
586
+ box.hidden = true;
587
+ });
588
+ box.append(close);
589
+ updaters.push(() => {
590
+ box.hidden = dismissed || form.node(alert.id).invisible;
591
+ });
592
+ }
593
+ else
594
+ hideWhen(box, alert.id);
482
595
  card.append(box);
483
596
  }
597
+ if (node.badges?.length) {
598
+ const badges = el('div', { class: 'fd-badges' });
599
+ for (const badge of node.badges) {
600
+ const chip = el('span', { class: `fd-badge fd-tone-${badge.tone ?? 'muted'}`, 'data-node': badge.id }, ...withIcon(badge.icon, badge.label));
601
+ hideWhen(chip, badge.id);
602
+ badges.append(chip);
603
+ }
604
+ updaters.push(() => {
605
+ badges.hidden = [...badges.children].every((chip) => chip.hidden);
606
+ });
607
+ card.append(badges);
608
+ }
484
609
  if (node.title) {
485
610
  const title = el('div', { class: 'fd-title' });
611
+ if (node.title.above?.length)
612
+ title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map(fieldItem)));
486
613
  title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
487
614
  if (node.title.subtitleField)
488
615
  title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
616
+ if (node.title.below?.length)
617
+ title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map(fieldItem)));
489
618
  const row = el('div', { class: 'fd-title-row' }, title);
490
619
  if (node.title.avatarField) {
491
620
  const avatar = fieldItem({ type: 'field', id: '#avatar', field: node.title.avatarField });
@@ -494,7 +623,15 @@ function mountViewer(host, options) {
494
623
  }
495
624
  card.append(row);
496
625
  }
626
+ if (node.statusbar && underTitle)
627
+ card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
497
628
  card.append(grid(node.children));
629
+ if (atFoot && options.showActions !== false) {
630
+ card.append(foot);
631
+ updaters.push((state) => {
632
+ foot.hidden = state.dirty.length === 0 && !statusText.textContent;
633
+ });
634
+ }
498
635
  const layout = el('div', { class: 'fd-sheet-layout' }, card);
499
636
  if (node.sidePanel) {
500
637
  layout.classList.add('fd-has-side');
@@ -511,7 +648,7 @@ function mountViewer(host, options) {
511
648
  barOptions['visibleStates'] = config.visibleStates;
512
649
  const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
513
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);
514
- 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 }));
515
652
  return widget.element;
516
653
  }
517
654
  let wizardForward = null;
@@ -526,8 +663,17 @@ function mountViewer(host, options) {
526
663
  else
527
664
  body = sectionsLayout(layout);
528
665
  const head = layout.type === 'sheet' ? null : pageHead();
529
- content.append(...(head ? [head] : []), draft, body);
530
- 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
+ }
531
677
  root.addEventListener('submit', (event) => {
532
678
  event.preventDefault();
533
679
  if (wizardForward)
@@ -535,13 +681,65 @@ function mountViewer(host, options) {
535
681
  else
536
682
  void submitOrSave();
537
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
+ });
538
735
  // ---- confirmation dialog -----------------------------------------------------
539
- function dialogConfirm(message) {
736
+ /** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
737
+ function dialogConfirm(message, withCancel = true) {
540
738
  return new Promise((resolve) => {
541
739
  const text = el('p', { id: `${prefix}-confirm` }, message);
542
740
  const cancel = el('button', { type: 'button', class: 'fd-button' }, labels.cancel);
543
741
  const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
544
- 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])));
545
743
  const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
546
744
  const close = (answer) => {
547
745
  backdrop.remove();
@@ -585,6 +783,12 @@ function mountViewer(host, options) {
585
783
  focusFirstProblem();
586
784
  return valid;
587
785
  },
786
+ setReadonly(readonly) {
787
+ locked = readonly;
788
+ root.toggleAttribute('data-readonly', locked);
789
+ render(form.getState());
790
+ },
791
+ isReadonly: () => locked,
588
792
  destroy() {
589
793
  unsubscribe();
590
794
  for (const cleanup of cleanups)
@@ -595,85 +799,6 @@ function mountViewer(host, options) {
595
799
  },
596
800
  };
597
801
  }
598
- /** The field a page's records are named by: its title, or "name", or its first line of text. */
599
- function nameFieldOf(page) {
600
- if (page.layout.type === 'sheet' && page.layout.title?.field)
601
- return page.layout.title.field;
602
- if (page.fields['name'])
603
- return 'name';
604
- return Object.entries(page.fields).find(([, def]) => def.type === 'char')?.[0] ?? null;
605
- }
606
- /** A form made of fields, for values edited in a dialog (a line, for one). */
607
- function valuesPage(fields, title, readonly) {
608
- const shown = Object.fromEntries(Object.entries(fields).map(([name, def]) => [name, readonly ? { ...def, readonly: true } : def]));
609
- return {
610
- fieldia: '0.1',
611
- id: 'values',
612
- title,
613
- data: { kind: 'record', model: 'values' },
614
- fields: shown,
615
- layout: {
616
- type: 'sections',
617
- id: 'values',
618
- children: [{ type: 'section', id: 'values-section', columns: 2, children: Object.keys(shown).map((name) => ({ type: 'field', id: `values-${name}`, field: name })) }],
619
- },
620
- };
621
- }
622
- /** Only finding and making linked records: values edited in a dialog are no record of the source's to load, save or recalculate. */
623
- function lookupsOf(source) {
624
- if (!source)
625
- return undefined;
626
- return {
627
- ...(source.search ? { search: (request) => source.search(request) } : {}),
628
- ...(source.create ? { create: (request) => source.create(request) } : {}),
629
- };
630
- }
631
- /** The dialogs a page's widgets may open, made from the viewer's own options. */
632
- function pageDialogs(options) {
633
- const pageFor = (model) => (typeof options.relatedPages === 'function' ? options.relatedPages(model) : options.relatedPages?.[model]) ?? null;
634
- // A dialog's page looks and reads like the page that opened it, and can open dialogs of its own.
635
- const shared = {
636
- locale: options.locale,
637
- skin: options.skin,
638
- dir: options.dir,
639
- widgets: options.widgets,
640
- preferences: options.preferences,
641
- relatedPages: options.relatedPages,
642
- };
643
- return {
644
- canOpen: (model) => pageFor(model) !== null,
645
- async openRecord(model, request) {
646
- const related = pageFor(model);
647
- if (!related)
648
- return null;
649
- const nameField = nameFieldOf(related);
650
- const result = await (0, dialog_1.openFormDialog)({
651
- ...shared,
652
- page: related,
653
- dataSource: options.dataSource,
654
- recordId: request.recordId ?? null,
655
- values: request.name && nameField ? { [nameField]: request.name } : undefined,
656
- title: request.title,
657
- size: 'large',
658
- });
659
- if (!result.saved || result.recordId === null)
660
- return null;
661
- const name = nameField ? result.values[nameField] : null;
662
- return { id: result.recordId, label: typeof name === 'string' && name ? name : request.name ?? request.title };
663
- },
664
- searchMore: (request) => (0, dialog_1.openSearchDialog)({ title: request.title, search: request.search, locale: options.locale, skin: options.skin, dir: options.dir }),
665
- async editValues(request) {
666
- const result = await (0, dialog_1.openFormDialog)({
667
- ...shared,
668
- page: valuesPage(request.fields, request.title, request.readonly === true),
669
- dataSource: lookupsOf(options.dataSource),
670
- values: request.values,
671
- title: request.title,
672
- mode: 'values',
673
- ...(request.recompute ? { recompute: request.recompute } : {}),
674
- });
675
- return result.saved ? result.values : null;
676
- },
677
- };
678
- }
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']);
679
804
  //# sourceMappingURL=viewer.js.map