@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.
@@ -1,90 +1,8 @@
1
- import { createForm, validatePage, MESSAGES, } from '@fieldia/core';
2
- import { browserPreferences, createWidget, installStyles, WIDGET_LABELS } from '@fieldia/widgets';
3
- import { openFormDialog, openSearchDialog } from './dialog.mjs';
4
- export const VIEWER_LABELS = {
5
- en: {
6
- save: 'Save',
7
- discard: 'Discard',
8
- saving: 'Saving…',
9
- saved: 'Saved',
10
- submit: 'Submit',
11
- next: 'Next',
12
- back: 'Back',
13
- stepOf: 'Step {n} of {total}',
14
- submitted: 'Thank you. Your answers were sent.',
15
- submitAnother: 'Submit another response',
16
- draftFound: 'You have unsaved answers from {time}.',
17
- restore: 'Restore',
18
- discardDraft: 'Discard',
19
- ok: 'OK',
20
- cancel: 'Cancel',
21
- loading: 'Loading…',
22
- saveClose: 'Save & Close',
23
- close: 'Close',
24
- },
25
- ar: {
26
- save: 'حفظ',
27
- discard: 'تجاهل',
28
- saving: 'جارٍ الحفظ…',
29
- saved: 'تم الحفظ',
30
- submit: 'إرسال',
31
- next: 'التالي',
32
- back: 'رجوع',
33
- stepOf: 'الخطوة {n} من {total}',
34
- submitted: 'شكرًا لك. تم إرسال إجاباتك.',
35
- submitAnother: 'إرسال إجابة أخرى',
36
- draftFound: 'لديك إجابات غير محفوظة من {time}.',
37
- restore: 'استعادة',
38
- discardDraft: 'تجاهل',
39
- ok: 'موافق',
40
- cancel: 'إلغاء',
41
- loading: 'جارٍ التحميل…',
42
- saveClose: 'حفظ وإغلاق',
43
- close: 'إغلاق',
44
- },
45
- de: {
46
- save: 'Speichern',
47
- discard: 'Verwerfen',
48
- saving: 'Wird gespeichert…',
49
- saved: 'Gespeichert',
50
- submit: 'Absenden',
51
- next: 'Weiter',
52
- back: 'Zurück',
53
- stepOf: 'Schritt {n} von {total}',
54
- submitted: 'Vielen Dank. Ihre Antworten wurden gesendet.',
55
- submitAnother: 'Weitere Antwort senden',
56
- draftFound: 'Sie haben ungespeicherte Antworten vom {time}.',
57
- restore: 'Wiederherstellen',
58
- discardDraft: 'Verwerfen',
59
- ok: 'OK',
60
- cancel: 'Abbrechen',
61
- loading: 'Wird geladen…',
62
- saveClose: 'Speichern und schließen',
63
- close: 'Schließen',
64
- },
65
- fr: {
66
- save: 'Enregistrer',
67
- discard: 'Annuler les modifications',
68
- saving: 'Enregistrement…',
69
- saved: 'Enregistré',
70
- submit: 'Envoyer',
71
- next: 'Suivant',
72
- back: 'Retour',
73
- stepOf: 'Étape {n} sur {total}',
74
- submitted: 'Merci. Vos réponses ont été envoyées.',
75
- submitAnother: 'Envoyer une autre réponse',
76
- draftFound: 'Vous avez des réponses non enregistrées du {time}.',
77
- restore: 'Restaurer',
78
- discardDraft: 'Ignorer',
79
- ok: 'OK',
80
- cancel: 'Annuler',
81
- loading: 'Chargement…',
82
- saveClose: 'Enregistrer et fermer',
83
- close: 'Fermer',
84
- },
85
- };
86
- /** English, kept under its old name. */
87
- export const DEFAULT_LABELS = VIEWER_LABELS.en;
1
+ import { createForm, checkPage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
2
+ import { browserPreferences, createWidget, drawIcon, installStyles, WIDGET_LABELS } from '@fieldia/widgets';
3
+ import { pageDialogs } from './related.mjs';
4
+ import { VIEWER_LABELS } from './labels.mjs';
5
+ export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
88
6
  let mounts = 0;
89
7
  /**
90
8
  * Render a page into `host` and keep it in step with its form. Plain DOM: the
@@ -93,11 +11,12 @@ let mounts = 0;
93
11
  */
94
12
  export function mountViewer(host, options) {
95
13
  const doc = host.ownerDocument;
96
- const checked = validatePage(options.page);
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);
97
16
  if (!checked.ok) {
98
17
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
99
18
  }
100
- const page = checked.page;
19
+ const page = options.translate ? translatePage(checked.page, options.translate) : checked.page;
101
20
  const locale = options.locale ?? 'en';
102
21
  const preferences = options.preferences ?? browserPreferences();
103
22
  const dialogs = pageDialogs(options);
@@ -120,7 +39,12 @@ export function mountViewer(host, options) {
120
39
  node.append(...children);
121
40
  return node;
122
41
  }
123
- const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: options.locale });
42
+ /** Words with the icon a page names before them, when there is such an icon. */
43
+ function withIcon(name, text) {
44
+ const icon = drawIcon(doc, name, options.icons);
45
+ return icon ? [icon, text] : [text];
46
+ }
47
+ const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': options.skin ?? 'underline', dir, lang: options.locale, 'data-max-width': page.maxWidth });
124
48
  const confirm = options.confirm ?? dialogConfirm;
125
49
  // ---- the parts -------------------------------------------------------
126
50
  function fieldItem(node) {
@@ -130,6 +54,12 @@ export function mountViewer(host, options) {
130
54
  if (node.colspan)
131
55
  wrapper.style.setProperty('--fd-span', String(node.colspan));
132
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
+ }
133
63
  const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
134
64
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
135
65
  // `for` stays: a custom field that puts the id on its own input is
@@ -141,7 +71,8 @@ export function mountViewer(host, options) {
141
71
  }
142
72
  const helpText = node.help ?? def.help;
143
73
  const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
144
- const error = el('div', { class: 'fd-error', id: `${id}-error`, role: 'alert', hidden: '' });
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: '' });
145
76
  // A warning from the data source's onchange, beside the field whose change brought it.
146
77
  const warning = el('div', { class: 'fd-warning', role: 'status', hidden: '' });
147
78
  wrapper.append(label, widget.element, ...(help ? [help] : []), error, warning);
@@ -157,10 +88,17 @@ export function mountViewer(host, options) {
157
88
  const warned = state.warning && state.warningField === node.field ? state.warning : '';
158
89
  warning.hidden = !warned;
159
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
+ }
160
98
  widget.update({
161
99
  value: state.values[node.field],
162
100
  values: state.values,
163
- readonly: shown.readonly,
101
+ readonly: shown.readonly || locked,
164
102
  required: shown.required,
165
103
  invalid: !!message,
166
104
  describedBy: [help?.id, message ? error.id : undefined].filter(Boolean).join(' ') || undefined,
@@ -174,7 +112,7 @@ export function mountViewer(host, options) {
174
112
  });
175
113
  }
176
114
  function buttonItem(node) {
177
- const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, node.label);
115
+ const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
178
116
  button.addEventListener('click', async () => {
179
117
  if (node.confirm && !(await confirm(node.confirm)))
180
118
  return;
@@ -199,7 +137,17 @@ export function mountViewer(host, options) {
199
137
  }
200
138
  function grid(children, columns = 1) {
201
139
  const box = el('div', { class: 'fd-grid' });
202
- box.style.setProperty('--fd-columns', String(columns));
140
+ box.style.setProperty('--fd-columns', String(wideColumns(columns)));
141
+ // Counts given for the narrower widths replace the skin's own stacking there.
142
+ if (typeof columns === 'object') {
143
+ for (const width of ['medium', 'narrow']) {
144
+ const count = columns[width];
145
+ if (count === undefined)
146
+ continue;
147
+ box.setAttribute(`data-columns-${width}`, String(count));
148
+ box.style.setProperty(`--fd-columns-${width}`, String(count));
149
+ }
150
+ }
203
151
  box.append(...children.map(item));
204
152
  return box;
205
153
  }
@@ -208,10 +156,10 @@ export function mountViewer(host, options) {
208
156
  function sectionItem(node) {
209
157
  const section = el('fieldset', { class: 'fd-section', 'data-node': node.id });
210
158
  const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
211
- const content = grid(node.children, node.columns ?? 1);
159
+ const content = grid(node.children, node.columns);
212
160
  if (node.title && node.collapsible) {
213
161
  content.id = uid(`${node.id}-content`);
214
- 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);
162
+ 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));
215
163
  let open = node.collapsed !== true;
216
164
  const show = (next) => {
217
165
  open = next;
@@ -227,7 +175,7 @@ export function mountViewer(host, options) {
227
175
  section.append(el('legend', { class: 'fd-section-title' }, toggle));
228
176
  }
229
177
  else if (node.title) {
230
- section.append(el('legend', { class: 'fd-section-title' }, node.title));
178
+ section.append(el('legend', { class: 'fd-section-title' }, ...withIcon(node.icon, node.title)));
231
179
  }
232
180
  if (description)
233
181
  section.append(description);
@@ -245,7 +193,7 @@ export function mountViewer(host, options) {
245
193
  const parts = node.children.map((tab) => {
246
194
  const tabId = uid(`${tab.id}-tab`);
247
195
  const panelId = uid(`${tab.id}-panel`);
248
- const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, tab.label);
196
+ 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));
249
197
  const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children));
250
198
  button.addEventListener('click', () => {
251
199
  picked = active = tab.id;
@@ -303,12 +251,101 @@ export function mountViewer(host, options) {
303
251
  }
304
252
  }
305
253
  // ---- shared chrome ----------------------------------------------------------
306
- const status = el('div', { class: 'fd-status', role: 'status', 'aria-live': 'polite' });
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;
307
336
  updaters.push((state) => {
308
- const text = state.status === 'saving' ? labels.saving : state.status === 'saved' ? labels.saved : state.status === 'loading' ? labels.loading : state.status === 'error' ? state.error ?? '' : '';
309
- status.textContent = text;
310
- status.classList.toggle('fd-status-error', state.status === 'error');
311
- status.classList.toggle('fd-status-saved', state.status === 'saved');
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);
312
349
  });
313
350
  const draft = el('div', { class: 'fd-draft', hidden: '' });
314
351
  const draftText = el('span');
@@ -342,6 +379,9 @@ export function mountViewer(host, options) {
342
379
  async function submitOrSave() {
343
380
  const saved = await form.save();
344
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()));
345
385
  focusFirstProblem();
346
386
  return;
347
387
  }
@@ -376,34 +416,66 @@ export function mountViewer(host, options) {
376
416
  discard.addEventListener('click', () => form.reset());
377
417
  actions.insertBefore(discard, action);
378
418
  updaters.push((state) => {
379
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
419
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
380
420
  action.hidden = !dirty;
381
421
  discard.hidden = !dirty;
382
422
  });
383
423
  }
384
- return options.showActions === false ? el('div', {}, box) : el('div', {}, box, actions);
424
+ else
425
+ updaters.push(() => (action.hidden = locked));
426
+ if (editSwitch)
427
+ actions.prepend(editSwitch);
428
+ if (options.showActions === false)
429
+ return el('div', {}, box);
430
+ if (page.actionsPosition === 'top') {
431
+ actions.classList.add('fd-actions-top');
432
+ return el('div', {}, actions, box);
433
+ }
434
+ return el('div', {}, box, actions);
385
435
  }
386
436
  function wizardLayout(node) {
387
437
  const box = el('div', { class: 'fd-wizard', 'data-node': node.id });
388
438
  const progressText = el('div', { class: 'fd-progress-text' });
389
439
  const bar = el('span');
390
440
  box.append(el('div', { class: 'fd-progress' }, progressText, el('div', { class: 'fd-progress-bar' }, bar)));
441
+ // A clickable wizard lists its steps: back to any, forward past those that are complete.
442
+ const list = node.clickable ? el('ol', { class: 'fd-steps-list' }) : null;
443
+ const stepButtons = new Map();
444
+ if (list) {
445
+ for (const step of node.children) {
446
+ const go = el('button', { type: 'button', class: 'fd-step-link' }, ...withIcon(step.icon, step.label));
447
+ go.addEventListener('click', () => {
448
+ if (form.goTo(step.id))
449
+ focusStep();
450
+ else
451
+ focusFirstProblem();
452
+ });
453
+ stepButtons.set(step.id, go);
454
+ list.append(el('li', {}, go));
455
+ }
456
+ box.append(el('nav', { class: 'fd-steps', 'aria-label': labels.steps }, list));
457
+ }
391
458
  const panels = node.children.map((step) => {
392
459
  const panel = el('section', { class: 'fd-step', 'data-node': step.id, 'aria-labelledby': uid(`${step.id}-title`) });
393
- panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, step.label));
460
+ panel.append(el('h2', { class: 'fd-step-title', id: uid(`${step.id}-title`) }, ...withIcon(step.icon, step.label)));
394
461
  if (step.description)
395
462
  panel.append(el('p', { class: 'fd-section-description' }, step.description));
396
463
  panel.append(grid(step.children));
397
464
  box.append(panel);
398
465
  return { step, panel };
399
466
  });
400
- const back = el('button', { type: 'button', class: 'fd-button' }, labels.back);
467
+ const back = el('button', { type: 'button', class: 'fd-button' }, node.backLabel ?? labels.back);
468
+ const skip = el('button', { type: 'button', class: 'fd-button fd-button-link' }, labels.skip);
401
469
  const forward = el('button', { type: 'submit', class: 'fd-button fd-button-primary' });
402
- box.append(el('div', { class: 'fd-wizard-nav' }, back, el('span', { class: 'fd-spacer' }), status, forward));
470
+ box.append(el('div', { class: 'fd-wizard-nav' }, back, el('span', { class: 'fd-spacer' }), status, skip, forward));
403
471
  back.addEventListener('click', () => {
404
472
  if (form.back())
405
473
  focusStep();
406
474
  });
475
+ skip.addEventListener('click', () => {
476
+ if (form.skip())
477
+ focusStep();
478
+ });
407
479
  const isLast = () => {
408
480
  const steps = form.steps();
409
481
  return steps.indexOf(form.getState().step ?? '') === steps.length - 1;
@@ -427,8 +499,20 @@ export function mountViewer(host, options) {
427
499
  bar.style.width = `${((index + 1) / Math.max(1, steps.length)) * 100}%`;
428
500
  for (const { step, panel } of panels)
429
501
  panel.hidden = step.id !== state.step;
502
+ const last = index === steps.length - 1;
430
503
  back.hidden = index === 0;
431
- forward.textContent = index === steps.length - 1 ? labels.submit : labels.next;
504
+ skip.hidden = last || !node.children.find((step) => step.id === state.step)?.optional;
505
+ forward.textContent = last ? node.finishLabel ?? labels.submit : node.nextLabel ?? labels.next;
506
+ for (const [id, go] of stepButtons) {
507
+ const at = steps.indexOf(id);
508
+ go.parentElement.hidden = at === -1;
509
+ go.classList.toggle('fd-step-done', at !== -1 && at < index);
510
+ go.classList.toggle('fd-step-skipped', state.skipped.includes(id));
511
+ if (id === state.step)
512
+ go.setAttribute('aria-current', 'step');
513
+ else
514
+ go.removeAttribute('aria-current');
515
+ }
432
516
  });
433
517
  return box;
434
518
  }
@@ -438,17 +522,28 @@ export function mountViewer(host, options) {
438
522
  const save = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, labels.save);
439
523
  const discard = el('button', { type: 'button', class: 'fd-button' }, labels.discard);
440
524
  discard.addEventListener('click', () => form.reset());
525
+ // Save and Discard lead the header bar, or close the sheet when the page puts them at its foot.
526
+ const atFoot = page.actionsPosition === 'bottom';
527
+ const foot = el('div', { class: 'fd-actions fd-actions-end fd-sheet-foot' });
441
528
  if (options.showActions === false)
442
529
  actions.append(...(node.buttons ?? []).map(buttonItem));
530
+ else if (atFoot) {
531
+ actions.append(...(node.buttons ?? []).map(buttonItem));
532
+ foot.append(status, discard, save);
533
+ }
443
534
  else
444
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);
445
539
  updaters.push((state) => {
446
- const dirty = state.dirty.length > 0 && state.status !== 'saving';
540
+ const dirty = state.dirty.length > 0 && state.status !== 'saving' && !locked;
447
541
  save.hidden = !dirty;
448
542
  discard.hidden = !dirty;
449
543
  });
450
544
  const header = el('div', { class: 'fd-header' }, actions);
451
- if (node.statusbar)
545
+ const underTitle = node.statusbar?.position === 'title';
546
+ if (node.statusbar && !underTitle)
452
547
  header.append(statusbar(node.statusbar));
453
548
  const card = el('div', { class: 'fd-card' });
454
549
  if (node.ribbon) {
@@ -461,7 +556,9 @@ export function mountViewer(host, options) {
461
556
  const stats = el('div', { class: 'fd-stats' });
462
557
  for (const stat of node.statButtons) {
463
558
  const value = el('span', { class: 'fd-stat-value' });
464
- const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, value, el('span', { class: 'fd-stat-label' }, stat.label));
559
+ const icon = drawIcon(doc, stat.icon, options.icons);
560
+ const words = el('span', { class: 'fd-stat-words' }, value, el('span', { class: 'fd-stat-label' }, stat.label));
561
+ const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, ...(icon ? [icon, words] : [words]));
465
562
  button.addEventListener('click', () => void form.runAction(stat.id));
466
563
  updaters.push((state) => {
467
564
  button.hidden = form.node(stat.id).invisible;
@@ -473,15 +570,45 @@ export function mountViewer(host, options) {
473
570
  card.append(stats);
474
571
  }
475
572
  for (const alert of node.alerts ?? []) {
476
- const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, alert.message);
477
- hideWhen(box, alert.id);
573
+ 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));
574
+ if (alert.dismissible) {
575
+ // Closed, it stays closed while the page is open, whatever its condition does.
576
+ let dismissed = false;
577
+ const close = el('button', { type: 'button', class: 'fd-alert-close', 'aria-label': labels.dismiss }, '×');
578
+ close.addEventListener('click', () => {
579
+ dismissed = true;
580
+ box.hidden = true;
581
+ });
582
+ box.append(close);
583
+ updaters.push(() => {
584
+ box.hidden = dismissed || form.node(alert.id).invisible;
585
+ });
586
+ }
587
+ else
588
+ hideWhen(box, alert.id);
478
589
  card.append(box);
479
590
  }
591
+ if (node.badges?.length) {
592
+ const badges = el('div', { class: 'fd-badges' });
593
+ for (const badge of node.badges) {
594
+ const chip = el('span', { class: `fd-badge fd-tone-${badge.tone ?? 'muted'}`, 'data-node': badge.id }, ...withIcon(badge.icon, badge.label));
595
+ hideWhen(chip, badge.id);
596
+ badges.append(chip);
597
+ }
598
+ updaters.push(() => {
599
+ badges.hidden = [...badges.children].every((chip) => chip.hidden);
600
+ });
601
+ card.append(badges);
602
+ }
480
603
  if (node.title) {
481
604
  const title = el('div', { class: 'fd-title' });
605
+ if (node.title.above?.length)
606
+ title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map(fieldItem)));
482
607
  title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
483
608
  if (node.title.subtitleField)
484
609
  title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
610
+ if (node.title.below?.length)
611
+ title.append(el('div', { class: 'fd-title-below' }, ...node.title.below.map(fieldItem)));
485
612
  const row = el('div', { class: 'fd-title-row' }, title);
486
613
  if (node.title.avatarField) {
487
614
  const avatar = fieldItem({ type: 'field', id: '#avatar', field: node.title.avatarField });
@@ -490,7 +617,15 @@ export function mountViewer(host, options) {
490
617
  }
491
618
  card.append(row);
492
619
  }
620
+ if (node.statusbar && underTitle)
621
+ card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
493
622
  card.append(grid(node.children));
623
+ if (atFoot && options.showActions !== false) {
624
+ card.append(foot);
625
+ updaters.push((state) => {
626
+ foot.hidden = state.dirty.length === 0 && !statusText.textContent;
627
+ });
628
+ }
494
629
  const layout = el('div', { class: 'fd-sheet-layout' }, card);
495
630
  if (node.sidePanel) {
496
631
  layout.classList.add('fd-has-side');
@@ -507,7 +642,7 @@ export function mountViewer(host, options) {
507
642
  barOptions['visibleStates'] = config.visibleStates;
508
643
  const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
509
644
  const widget = createWidget({ form, name: config.field, field: def, node, id: uid('statusbar'), document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
510
- 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 }));
511
646
  return widget.element;
512
647
  }
513
648
  let wizardForward = null;
@@ -522,8 +657,17 @@ export function mountViewer(host, options) {
522
657
  else
523
658
  body = sectionsLayout(layout);
524
659
  const head = layout.type === 'sheet' ? null : pageHead();
525
- content.append(...(head ? [head] : []), draft, body);
526
- 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
+ }
527
671
  root.addEventListener('submit', (event) => {
528
672
  event.preventDefault();
529
673
  if (wizardForward)
@@ -531,13 +675,65 @@ export function mountViewer(host, options) {
531
675
  else
532
676
  void submitOrSave();
533
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
+ const keys = options.keys ?? {};
702
+ if (event.ctrlKey || event.metaKey) {
703
+ // Saves even after a field used the Enter (a tag box adding its tag); the grid keeps its own keys.
704
+ if (keys.saveWithCtrlEnter === false || options.showActions === false)
705
+ return;
706
+ if (event.target.closest('.ag-root-wrapper'))
707
+ return;
708
+ event.preventDefault();
709
+ commitTyping();
710
+ if (wizardForward)
711
+ void wizardForward();
712
+ else
713
+ void submitOrSave();
714
+ return;
715
+ }
716
+ // A plain Enter a field used itself, such as picking from an open list, is the field's.
717
+ if (event.defaultPrevented || !keys.enterMovesToNext || event.shiftKey || event.altKey)
718
+ return;
719
+ const from = event.target;
720
+ if (from.tagName !== 'INPUT' && from.tagName !== 'SELECT')
721
+ return;
722
+ if (from.closest('[data-type="one2many"]'))
723
+ return;
724
+ if (['button', 'submit', 'reset', 'file'].includes(from.type))
725
+ return;
726
+ event.preventDefault();
727
+ nextField(from)?.focus();
728
+ });
534
729
  // ---- confirmation dialog -----------------------------------------------------
535
- function dialogConfirm(message) {
730
+ /** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
731
+ function dialogConfirm(message, withCancel = true) {
536
732
  return new Promise((resolve) => {
537
733
  const text = el('p', { id: `${prefix}-confirm` }, message);
538
734
  const cancel = el('button', { type: 'button', class: 'fd-button' }, labels.cancel);
539
735
  const ok = el('button', { type: 'button', class: 'fd-button fd-button-primary' }, labels.ok);
540
- 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));
736
+ 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])));
541
737
  const backdrop = el('div', { class: 'fd-dialog-backdrop' }, dialog);
542
738
  const close = (answer) => {
543
739
  backdrop.remove();
@@ -581,6 +777,12 @@ export function mountViewer(host, options) {
581
777
  focusFirstProblem();
582
778
  return valid;
583
779
  },
780
+ setReadonly(readonly) {
781
+ locked = readonly;
782
+ root.toggleAttribute('data-readonly', locked);
783
+ render(form.getState());
784
+ },
785
+ isReadonly: () => locked,
584
786
  destroy() {
585
787
  unsubscribe();
586
788
  for (const cleanup of cleanups)
@@ -591,85 +793,6 @@ export function mountViewer(host, options) {
591
793
  },
592
794
  };
593
795
  }
594
- /** The field a page's records are named by: its title, or "name", or its first line of text. */
595
- function nameFieldOf(page) {
596
- if (page.layout.type === 'sheet' && page.layout.title?.field)
597
- return page.layout.title.field;
598
- if (page.fields['name'])
599
- return 'name';
600
- return Object.entries(page.fields).find(([, def]) => def.type === 'char')?.[0] ?? null;
601
- }
602
- /** A form made of fields, for values edited in a dialog (a line, for one). */
603
- function valuesPage(fields, title, readonly) {
604
- const shown = Object.fromEntries(Object.entries(fields).map(([name, def]) => [name, readonly ? { ...def, readonly: true } : def]));
605
- return {
606
- fieldia: '0.1',
607
- id: 'values',
608
- title,
609
- data: { kind: 'record', model: 'values' },
610
- fields: shown,
611
- layout: {
612
- type: 'sections',
613
- id: 'values',
614
- children: [{ type: 'section', id: 'values-section', columns: 2, children: Object.keys(shown).map((name) => ({ type: 'field', id: `values-${name}`, field: name })) }],
615
- },
616
- };
617
- }
618
- /** Only finding and making linked records: values edited in a dialog are no record of the source's to load, save or recalculate. */
619
- function lookupsOf(source) {
620
- if (!source)
621
- return undefined;
622
- return {
623
- ...(source.search ? { search: (request) => source.search(request) } : {}),
624
- ...(source.create ? { create: (request) => source.create(request) } : {}),
625
- };
626
- }
627
- /** The dialogs a page's widgets may open, made from the viewer's own options. */
628
- function pageDialogs(options) {
629
- const pageFor = (model) => (typeof options.relatedPages === 'function' ? options.relatedPages(model) : options.relatedPages?.[model]) ?? null;
630
- // A dialog's page looks and reads like the page that opened it, and can open dialogs of its own.
631
- const shared = {
632
- locale: options.locale,
633
- skin: options.skin,
634
- dir: options.dir,
635
- widgets: options.widgets,
636
- preferences: options.preferences,
637
- relatedPages: options.relatedPages,
638
- };
639
- return {
640
- canOpen: (model) => pageFor(model) !== null,
641
- async openRecord(model, request) {
642
- const related = pageFor(model);
643
- if (!related)
644
- return null;
645
- const nameField = nameFieldOf(related);
646
- const result = await openFormDialog({
647
- ...shared,
648
- page: related,
649
- dataSource: options.dataSource,
650
- recordId: request.recordId ?? null,
651
- values: request.name && nameField ? { [nameField]: request.name } : undefined,
652
- title: request.title,
653
- size: 'large',
654
- });
655
- if (!result.saved || result.recordId === null)
656
- return null;
657
- const name = nameField ? result.values[nameField] : null;
658
- return { id: result.recordId, label: typeof name === 'string' && name ? name : request.name ?? request.title };
659
- },
660
- searchMore: (request) => openSearchDialog({ title: request.title, search: request.search, locale: options.locale, skin: options.skin, dir: options.dir }),
661
- async editValues(request) {
662
- const result = await openFormDialog({
663
- ...shared,
664
- page: valuesPage(request.fields, request.title, request.readonly === true),
665
- dataSource: lookupsOf(options.dataSource),
666
- values: request.values,
667
- title: request.title,
668
- mode: 'values',
669
- ...(request.recompute ? { recompute: request.recompute } : {}),
670
- });
671
- return result.saved ? result.values : null;
672
- },
673
- };
674
- }
796
+ /** Fields whose ✓ would say nothing: a yes/no box is never wrong, a table or a file has its own look. */
797
+ const NO_VALID_MARK = new Set(['boolean', 'one2many', 'binary', 'image', 'html', 'json', 'properties']);
675
798
  //# sourceMappingURL=viewer.mjs.map