@fieldia/viewer 0.11.0 → 0.12.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.
Files changed (103) hide show
  1. package/bundle/fieldia.ar.js +2 -2
  2. package/bundle/fieldia.ar.js.map +2 -2
  3. package/bundle/fieldia.de.js +2 -2
  4. package/bundle/fieldia.de.js.map +2 -2
  5. package/bundle/fieldia.fr.js +2 -2
  6. package/bundle/fieldia.fr.js.map +2 -2
  7. package/bundle/fieldia.js +6 -4
  8. package/bundle/fieldia.js.map +4 -4
  9. package/package.json +3 -3
  10. package/src/lib/attachment-preview.d.mts +27 -0
  11. package/src/lib/attachment-preview.d.ts +27 -0
  12. package/src/lib/attachment-preview.js +89 -0
  13. package/src/lib/attachment-preview.js.map +1 -0
  14. package/src/lib/attachment-preview.mjs +86 -0
  15. package/src/lib/attachment-preview.mjs.map +1 -0
  16. package/src/lib/dialog.d.mts +2 -0
  17. package/src/lib/dialog.d.ts +2 -0
  18. package/src/lib/dialog.js +65 -4
  19. package/src/lib/dialog.js.map +1 -1
  20. package/src/lib/dialog.mjs +64 -4
  21. package/src/lib/dialog.mjs.map +1 -1
  22. package/src/lib/hotkeys.d.mts +11 -0
  23. package/src/lib/hotkeys.d.ts +11 -0
  24. package/src/lib/hotkeys.js +34 -0
  25. package/src/lib/hotkeys.js.map +1 -0
  26. package/src/lib/hotkeys.mjs +30 -0
  27. package/src/lib/hotkeys.mjs.map +1 -0
  28. package/src/lib/labels.d.mts +25 -0
  29. package/src/lib/labels.d.ts +25 -0
  30. package/src/lib/labels.js +17 -0
  31. package/src/lib/labels.js.map +1 -1
  32. package/src/lib/labels.mjs +17 -0
  33. package/src/lib/labels.mjs.map +1 -1
  34. package/src/lib/list.d.mts +3 -1
  35. package/src/lib/list.d.ts +3 -1
  36. package/src/lib/list.js +1 -1
  37. package/src/lib/list.js.map +1 -1
  38. package/src/lib/list.mjs +1 -1
  39. package/src/lib/list.mjs.map +1 -1
  40. package/src/lib/locales/ar.js +17 -0
  41. package/src/lib/locales/ar.js.map +1 -1
  42. package/src/lib/locales/ar.mjs +17 -0
  43. package/src/lib/locales/ar.mjs.map +1 -1
  44. package/src/lib/locales/de.js +17 -0
  45. package/src/lib/locales/de.js.map +1 -1
  46. package/src/lib/locales/de.mjs +17 -0
  47. package/src/lib/locales/de.mjs.map +1 -1
  48. package/src/lib/locales/fr.js +17 -0
  49. package/src/lib/locales/fr.js.map +1 -1
  50. package/src/lib/locales/fr.mjs +17 -0
  51. package/src/lib/locales/fr.mjs.map +1 -1
  52. package/src/lib/open.d.mts +7 -1
  53. package/src/lib/open.d.ts +7 -1
  54. package/src/lib/open.js +19 -8
  55. package/src/lib/open.js.map +1 -1
  56. package/src/lib/open.mjs +20 -9
  57. package/src/lib/open.mjs.map +1 -1
  58. package/src/lib/place.d.mts +2 -0
  59. package/src/lib/place.d.ts +2 -0
  60. package/src/lib/place.js.map +1 -1
  61. package/src/lib/place.mjs.map +1 -1
  62. package/src/lib/record-bar.d.mts +56 -0
  63. package/src/lib/record-bar.d.ts +56 -0
  64. package/src/lib/record-bar.js +237 -0
  65. package/src/lib/record-bar.js.map +1 -0
  66. package/src/lib/record-bar.mjs +234 -0
  67. package/src/lib/record-bar.mjs.map +1 -0
  68. package/src/lib/record-styles.d.mts +9 -0
  69. package/src/lib/record-styles.d.ts +9 -0
  70. package/src/lib/record-styles.js +83 -0
  71. package/src/lib/record-styles.js.map +1 -0
  72. package/src/lib/record-styles.mjs +79 -0
  73. package/src/lib/record-styles.mjs.map +1 -0
  74. package/src/lib/related.d.mts +7 -3
  75. package/src/lib/related.d.ts +7 -3
  76. package/src/lib/related.js +22 -8
  77. package/src/lib/related.js.map +1 -1
  78. package/src/lib/related.mjs +22 -8
  79. package/src/lib/related.mjs.map +1 -1
  80. package/src/lib/say.d.mts +3 -1
  81. package/src/lib/say.d.ts +3 -1
  82. package/src/lib/say.js +17 -1
  83. package/src/lib/say.js.map +1 -1
  84. package/src/lib/say.mjs +17 -1
  85. package/src/lib/say.mjs.map +1 -1
  86. package/src/lib/tab-strip.d.mts +17 -0
  87. package/src/lib/tab-strip.d.ts +17 -0
  88. package/src/lib/tab-strip.js +51 -0
  89. package/src/lib/tab-strip.js.map +1 -0
  90. package/src/lib/tab-strip.mjs +48 -0
  91. package/src/lib/tab-strip.mjs.map +1 -0
  92. package/src/lib/value-words.d.mts +10 -0
  93. package/src/lib/value-words.d.ts +10 -0
  94. package/src/lib/value-words.js +38 -0
  95. package/src/lib/value-words.js.map +1 -0
  96. package/src/lib/value-words.mjs +35 -0
  97. package/src/lib/value-words.mjs.map +1 -0
  98. package/src/lib/viewer.d.mts +22 -1
  99. package/src/lib/viewer.d.ts +22 -1
  100. package/src/lib/viewer.js +488 -46
  101. package/src/lib/viewer.js.map +1 -1
  102. package/src/lib/viewer.mjs +489 -47
  103. package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.js CHANGED
@@ -11,6 +11,12 @@ const place_1 = require("./place");
11
11
  const dom_1 = require("./dom");
12
12
  const open_1 = require("./open");
13
13
  const say_1 = require("./say");
14
+ const tab_strip_1 = require("./tab-strip");
15
+ const value_words_1 = require("./value-words");
16
+ const hotkeys_1 = require("./hotkeys");
17
+ const record_bar_1 = require("./record-bar");
18
+ const attachment_preview_1 = require("./attachment-preview");
19
+ const record_styles_1 = require("./record-styles");
14
20
  const labels_1 = require("./labels");
15
21
  const focus_trap_1 = require("./focus-trap");
16
22
  var labels_2 = require("./labels");
@@ -25,7 +31,8 @@ let mounts = 0;
25
31
  function mountViewer(host, options) {
26
32
  const doc = host.ownerDocument;
27
33
  // The quick check of names and conditions: the full one, with its validation library, belongs where pages are made.
28
- const checked = (0, core_1.checkPage)(options.page);
34
+ // A line's own form reads the record the line is on as parent.
35
+ const checked = (0, core_1.checkPage)(options.page, options.parent ? { parent: options.parent.fields } : {});
29
36
  if (!checked.ok) {
30
37
  throw new Error(`This page cannot be shown:\n${checked.issues.map((i) => ` ${i.path}: ${i.message}`).join('\n')}`);
31
38
  }
@@ -40,7 +47,7 @@ function mountViewer(host, options) {
40
47
  };
41
48
  const locale = (0, labels_1.ownLocale)(tag);
42
49
  const preferences = options.preferences ?? (0, widgets_1.browserPreferences)();
43
- const dialogs = (0, related_1.pageDialogs)(options);
50
+ const dialogs = (0, related_1.pageDialogs)(options, (message, tone) => actionHost.say(message, tone));
44
51
  const ownsForm = !options.form;
45
52
  const labels = { ...labels_1.VIEWER_LABELS[locale], ...options.labels };
46
53
  /**
@@ -49,7 +56,17 @@ function mountViewer(host, options) {
49
56
  * made elsewhere (`form`) keeps the host it was made with.
50
57
  */
51
58
  const actionHost = {
52
- open: (request) => (0, open_1.openPage)({ options, place: host, root, title: (other) => shownAs(other).title || other.id, back: labels.back, missing: (id) => fill(labels.pageMissing, { page: id }) }, request),
59
+ open: (request) => (0, open_1.openPage)({
60
+ options,
61
+ place: host,
62
+ root,
63
+ title: (other) => shownAs(other).title || other.id,
64
+ back: labels.back,
65
+ missing: (id) => fill(labels.pageMissing, { page: id }),
66
+ say: (message, tone) => actionHost.say(message, tone),
67
+ post: (message) => form.post(message),
68
+ name: () => recordName() || labels.newRecord,
69
+ }, request),
53
70
  say: (message, tone) => say(message, tone),
54
71
  ask: (message) => confirm(message),
55
72
  show: (target) => {
@@ -57,6 +74,8 @@ function mountViewer(host, options) {
57
74
  if (!show?.())
58
75
  throw new Error(`No tab "${target}" can be shown here`);
59
76
  },
77
+ // A new tab opened apart from this page, or this one sent there.
78
+ openUrl: (url, newTab) => (newTab ? doc.defaultView?.open(url, '_blank', 'noopener,noreferrer') : doc.defaultView?.location.assign(url)),
60
79
  ...options.host,
61
80
  };
62
81
  const form = options.form ?? (0, core_1.createForm)({ ...options, page, host: actionHost, messages: options.messages ?? core_1.MESSAGES[locale] });
@@ -64,11 +83,17 @@ function mountViewer(host, options) {
64
83
  const dir = options.dir ?? (tag && (0, core_1.isRightToLeft)(tag) ? 'rtl' : undefined);
65
84
  const prefix = `fd${++mounts}`;
66
85
  const updaters = [];
86
+ /**
87
+ * Where a part's updater goes as it is drawn: the form's own list, or the list
88
+ * of the tab it is drawn in, which runs only while that tab is shown.
89
+ */
90
+ let sink = updaters;
67
91
  const cleanups = [];
68
92
  const ownUid = (id) => `${prefix}-${id.replace(/[^A-Za-z0-9_-]/g, '_')}`;
69
93
  const uid = ownUid;
70
94
  const fill = (template, values) => template.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
71
95
  (0, widgets_1.installStyles)(doc);
96
+ (0, record_styles_1.installRecordStyles)(doc);
72
97
  function el(tag, attrs = {}, ...children) {
73
98
  const node = doc.createElement(tag);
74
99
  for (const [name, value] of Object.entries(attrs))
@@ -93,6 +118,8 @@ function mountViewer(host, options) {
93
118
  const say = (0, say_1.sayer)(root, el, labels.dismiss);
94
119
  /** The tabs a step can show, by id: each shows its tab, false when it is hidden. */
95
120
  const tabs = new Map();
121
+ /** For each field in a tab, how to show its tab: a problem there is shown before the focus goes to it. */
122
+ const problemTabs = new Map();
96
123
  /**
97
124
  * A press runs its steps one run at a time: the button busy meanwhile, and
98
125
  * not pressed again. A check or a save that stops it takes the focus to the
@@ -117,6 +144,8 @@ function mountViewer(host, options) {
117
144
  }
118
145
  }
119
146
  // ---- the parts -------------------------------------------------------
147
+ /** The fields that take the focus as the record opens, in the order they are drawn. */
148
+ const focusers = [];
120
149
  /** Folded sections, and how to open each: a problem inside one has to be seen. */
121
150
  const folds = new Map();
122
151
  /** Every part drawn again from the state of the page shown. */
@@ -139,13 +168,13 @@ function mountViewer(host, options) {
139
168
  const { form, page } = scope;
140
169
  const uid = (id) => ownUid(scope.prefix + id);
141
170
  /** A part's updater, handed the state of the part's own form. */
142
- const watch = (update) => updaters.push(scope.root ? update : () => update(form.getState()));
143
- /** A field with its label, help and messages. `labels` is where labels sit where it is put; the title's own fields take none. */
144
- function fieldItem(node, labels) {
171
+ const watch = (update) => sink.push(scope.root ? update : () => update(form.getState()));
172
+ /** A field with its label, help and messages. `place` is where labels sit where it is put; the title's own fields take none. */
173
+ function fieldItem(node, place) {
145
174
  const def = page.fields[node.field];
146
175
  const id = uid(node.id);
147
- const labelsAt = (0, place_1.labelPlace)(node, def.type, labels);
148
- const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-path': scope.path + node.field, 'data-type': def.type, 'data-labels': labelsAt });
176
+ const labelsAt = (0, place_1.labelPlace)(node, def.type, place);
177
+ const wrapper = el('div', { class: 'fd-field', 'data-node': node.id, 'data-field': node.field, 'data-path': scope.path + node.field, 'data-type': def.type, 'data-labels': labelsAt, 'data-focus': node.focus && scope.root ? '' : undefined });
149
178
  if (node.colspan)
150
179
  wrapper.style.setProperty('--fd-span', String(node.colspan));
151
180
  const labelText = node.label ?? def.label;
@@ -157,11 +186,16 @@ function mountViewer(host, options) {
157
186
  label.append(mark);
158
187
  }
159
188
  const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
189
+ // The field that takes the focus as the record opens (Flectra's default_focus): the page's own, not a saved form's inside it.
190
+ if (node.focus && scope.root)
191
+ focusers.push({ wrapper, focus: () => widget.focus() });
160
192
  // A label kept out of sight still names the box; the empty box shows it instead, unless the page gives it words of its own.
161
- if (labelsAt === 'hidden' && !node.placeholder) {
162
- const box = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
163
- box?.setAttribute('placeholder', labelText);
164
- }
193
+ const textBox = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
194
+ if (labelsAt === 'hidden' && !node.placeholder)
195
+ textBox?.setAttribute('placeholder', labelText);
196
+ // Words chosen by a condition (placeholderWhen), else what the box showed as it was drawn.
197
+ const drawnPlaceholder = textBox?.getAttribute('placeholder') ?? null;
198
+ const chosenPlaceholder = node.placeholderWhen?.length ? textBox : null;
165
199
  if (!['INPUT', 'SELECT', 'TEXTAREA'].includes(widget.element.tagName)) {
166
200
  // `for` stays: a custom field that puts the id on its own input is
167
201
  // labelled natively. Only a wrapper with a role (a radio group, say) may
@@ -172,12 +206,24 @@ function mountViewer(host, options) {
172
206
  label.addEventListener('click', () => widget.focus());
173
207
  }
174
208
  const helpText = node.help ?? def.help;
175
- const help = helpText ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
209
+ // Under the field, behind a (?) by its label, or both: the field's own way, else the page's.
210
+ const helpWay = node.helpShown ?? page.look?.helpShown ?? 'below';
211
+ const help = helpText && helpWay !== 'tooltip' ? el('div', { class: 'fd-help', id: `${id}-help` }, helpText) : null;
212
+ const tip = helpText && helpWay !== 'below' ? helpTip(`${id}-tip`, helpText, fill(labels.helpFor, { label: labelText })) : null;
213
+ // In the label, as Flectra's: a press on it does not move into the box. A label out of sight keeps it after the box.
214
+ if (tip && labelsAt === 'hidden')
215
+ widget.element.after(tip.element);
216
+ else if (tip)
217
+ label.append(tip.element);
176
218
  // Not an alert of its own: a refused save is announced once, naming every field to look at.
177
219
  const error = el('div', { class: 'fd-error', id: `${id}-error`, hidden: '' });
178
220
  // A warning from an answer rule, and one from the data source's onchange beside the field whose change brought it.
179
221
  const warning = el('div', { class: 'fd-warning', id: `${id}-warning`, role: 'status', hidden: '' });
180
222
  wrapper.append(...(labelsAt === 'after' ? [widget.element, label] : [label, widget.element]), ...(help ? [help] : []), error, warning);
223
+ // Read-only, as words in place of the box, when the page draws them so.
224
+ const asText = page.look?.readonlyShown === 'text' && (0, widgets_1.readsAsText)(def, node) ? (0, widgets_1.readText)({ document: doc, field: def, node, id, locale, labels: widgetLabels, dialogs }) : null;
225
+ if (asText)
226
+ widget.element.after(asText.element);
181
227
  if (widget.destroy)
182
228
  cleanups.push(() => widget.destroy?.());
183
229
  // An answer rule's warning waits until the person leaves the field: no advice
@@ -193,10 +239,19 @@ function mountViewer(host, options) {
193
239
  // (a deleted line's button), and that redraw must finish first.
194
240
  queueMicrotask(() => update(form.getState()));
195
241
  });
242
+ const toned = node.tones !== undefined || node.bold !== undefined;
196
243
  const update = (state) => {
197
244
  const shown = form.node(node.id);
198
245
  (0, dom_1.setHidden)(wrapper, shown.invisible);
199
246
  wrapper.classList.toggle('fd-required', shown.required);
247
+ // Its value's tone and bold while a condition holds: Flectra's decoration-* on a field.
248
+ if (toned) {
249
+ const look = form.fieldTone(node.id);
250
+ (0, dom_1.setAttr)(wrapper, 'data-tone', look.tone);
251
+ wrapper.classList.toggle('fd-value-bold', look.bold);
252
+ }
253
+ if (chosenPlaceholder)
254
+ (0, dom_1.setAttr)(chosenPlaceholder, 'placeholder', form.placeholder(node.id) ?? drawnPlaceholder);
200
255
  const message = state.errors[node.field];
201
256
  (0, dom_1.setHidden)(error, !message);
202
257
  (0, dom_1.setText)(error, message ?? '');
@@ -214,34 +269,80 @@ function mountViewer(host, options) {
214
269
  wrapper.classList.toggle('fd-valid', valid);
215
270
  mark.toggleAttribute('hidden', !valid);
216
271
  }
272
+ const readonly = shown.readonly || locked || scope.locked();
273
+ if (asText) {
274
+ (0, dom_1.setHidden)(widget.element, readonly);
275
+ (0, dom_1.setHidden)(asText.element, !readonly);
276
+ (0, dom_1.setAttr)(wrapper, 'data-read-text', readonly ? '' : null);
277
+ if (readonly)
278
+ asText.update(state.values[node.field], state.values);
279
+ }
217
280
  widget.update({
218
281
  value: state.values[node.field],
219
282
  values: state.values,
220
- readonly: shown.readonly || locked || scope.locked(),
283
+ readonly,
221
284
  required: shown.required,
222
285
  invalid: !!message,
223
- describedBy: [help?.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
286
+ describedBy: [help?.id ?? tip?.bubble.id, message ? error.id : undefined, warned ? warning.id : undefined].filter(Boolean).join(' ') || undefined,
224
287
  });
225
288
  };
226
289
  watch(update);
227
290
  return wrapper;
228
291
  }
292
+ /** A (?) that shows a field's help in a bubble: on hover, on focus, or kept open by a press — a tap — and gone with Escape. */
293
+ function helpTip(bubbleId, text, name) {
294
+ const bubble = el('span', { class: 'fd-help-bubble', role: 'tooltip', id: bubbleId, hidden: '' }, text);
295
+ const button = el('button', { type: 'button', class: 'fd-help-tip', 'aria-label': name, 'aria-expanded': 'false', 'aria-controls': bubbleId }, '?');
296
+ let kept = false;
297
+ const show = (open) => {
298
+ bubble.hidden = !open;
299
+ button.setAttribute('aria-expanded', String(open));
300
+ };
301
+ button.addEventListener('mouseenter', () => show(true));
302
+ button.addEventListener('mouseleave', () => show(kept));
303
+ button.addEventListener('focus', () => show(true));
304
+ button.addEventListener('blur', () => ((kept = false), show(false)));
305
+ button.addEventListener('click', (event) => {
306
+ event.preventDefault();
307
+ kept = !kept;
308
+ show(kept);
309
+ });
310
+ button.addEventListener('keydown', (event) => {
311
+ if (event.key !== 'Escape' || bubble.hidden)
312
+ return;
313
+ // Escape closes the bubble, not the dialog the form is in.
314
+ event.stopPropagation();
315
+ kept = false;
316
+ show(false);
317
+ });
318
+ return { element: el('span', { class: 'fd-help-tip-wrap' }, button, bubble), bubble };
319
+ }
229
320
  function hideWhen(element, id) {
230
321
  watch(() => (0, dom_1.setHidden)(element, form.node(id).invisible));
231
322
  }
232
323
  function buttonItem(node) {
233
324
  const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
325
+ if (node.hotkey)
326
+ (0, hotkeys_1.hotkeyOn)(button, node.hotkey, node.label);
234
327
  spans(button, node.colspan);
235
328
  // Its confirmation, then its steps: the form asks, through the viewer.
236
329
  button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
237
330
  hideWhen(button, node.id);
238
- return button;
331
+ // A header's button is drawn here too, never through item(): hidden at its widths the same way.
332
+ return hiddenOn(node, button);
239
333
  }
240
- function textItem(node) {
334
+ function textItem(node, inline = false) {
241
335
  const style = node.style ?? 'paragraph';
242
- const element = el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, node.text);
336
+ // Its words, with the values of the fields they name.
337
+ const words = (0, value_words_1.valueWords)(doc, page.fields, node.text, locale);
338
+ const element = inline
339
+ ? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...words.nodes)
340
+ : style === 'alert'
341
+ ? el('div', { class: `fd-alert fd-text-alert fd-tone-${node.tone ?? 'info'}`, role: 'status', 'data-node': node.id }, el('span', { class: 'fd-alert-message' }, ...words.nodes))
342
+ : el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...words.nodes);
243
343
  spans(element, node.colspan);
244
344
  hideWhen(element, node.id);
345
+ watch((state) => words.update(state.values));
245
346
  return element;
246
347
  }
247
348
  function slotItem(node) {
@@ -260,6 +361,9 @@ function mountViewer(host, options) {
260
361
  const box = el('div', { class: 'fd-grid' });
261
362
  if (columns !== null) {
262
363
  box.style.setProperty('--fd-columns', String((0, core_1.wideColumns)(columns)));
364
+ // Twelfths: eleven gaps between them, which shrink in a narrow column rather than push past it.
365
+ if ((0, core_1.wideColumns)(columns) === 12)
366
+ box.setAttribute('data-twelfths', '');
263
367
  // Counts given for the narrower widths replace the skin's own stacking there.
264
368
  if (typeof columns === 'object') {
265
369
  for (const width of ['medium', 'narrow']) {
@@ -274,7 +378,44 @@ function mountViewer(host, options) {
274
378
  box.append(...children.map((child) => item(child, place)));
275
379
  return box;
276
380
  }
381
+ /**
382
+ * A row of twelfths led by a field whose label sits beside it, as Flectra's
383
+ * `<label/><div class="o_row">`: that label goes with the labels round it,
384
+ * and the parts share the room their values have.
385
+ */
386
+ function labelledRow(node, plan) {
387
+ const [first] = node.children;
388
+ if (!plan.arrangement || plan.at === 'tracks' || (0, core_1.wideColumns)(node.columns) !== 12 || node.children.length < 2 || first?.type !== 'field')
389
+ return null;
390
+ // One line of them: parts running past twelve twelfths are a column of fields, each with its own label.
391
+ if (node.children.reduce((sum, child) => sum + (child.colspan ?? 1), 0) > 12)
392
+ return null;
393
+ const type = page.fields[first.field]?.type;
394
+ const where = type ? (0, place_1.labelPlace)(first, type, plan.inner.labels) : 'hidden';
395
+ // The skin's place: beside in the underline skin, above in the outlined one, as the stylesheet says.
396
+ return where === undefined ? 'skin' : where === 'beside' ? 'beside' : null;
397
+ }
398
+ /**
399
+ * A line of parts, as Flectra's `<label/><div class="o_row">`: its title as
400
+ * the line's label, where the labels round it sit, and its parts after it,
401
+ * each as wide as it needs, its fields named for a screen reader.
402
+ */
403
+ function inlineItem(node, place) {
404
+ const line = el('div', { class: 'fd-field fd-oneline', 'data-node': node.id, 'data-style': 'inline', 'data-labels': place.labels });
405
+ spans(line, node.colspan);
406
+ const row = el('div', { class: 'fd-oneline-row', role: 'group' }, ...node.children.map((child) => item(child, { columns: 1, onPage: false, inline: true })));
407
+ if (node.title) {
408
+ const title = el('span', { class: 'fd-label', id: uid(`${node.id}-label`) }, node.title);
409
+ row.setAttribute('aria-labelledby', title.id);
410
+ line.append(title);
411
+ }
412
+ line.append(row);
413
+ hideWhen(line, node.id);
414
+ return line;
415
+ }
277
416
  function sectionItem(node, place) {
417
+ if (node.style === 'inline')
418
+ return inlineItem(node, place);
278
419
  const plan = (0, place_1.planSection)(node, place);
279
420
  // An arrangement is no group to name, and a fieldset cannot lay its parts on the columns round it.
280
421
  const section = el(plan.arrangement ? 'div' : 'fieldset', {
@@ -285,6 +426,9 @@ function mountViewer(host, options) {
285
426
  'data-on-page': plan.style === 'card' && place.onPage ? '' : undefined,
286
427
  });
287
428
  spans(section, node.colspan);
429
+ const row = labelledRow(node, plan);
430
+ if (row)
431
+ section.setAttribute('data-row', row);
288
432
  if (node.labelWidth)
289
433
  section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
290
434
  const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
@@ -333,13 +477,39 @@ function mountViewer(host, options) {
333
477
  const tabId = uid(`${tab.id}-tab`);
334
478
  const panelId = uid(`${tab.id}-panel`);
335
479
  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));
336
- // A tab's parts sit where the tabs do: on the page, or in the box round them.
337
- const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId }, grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
480
+ const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId });
481
+ /**
482
+ * A tab's parts are drawn when it is first shown, and kept up to date
483
+ * only while it is shown: a page of many tabs costs one tab's work.
484
+ */
485
+ const own = [];
486
+ let built = false;
487
+ const build = () => {
488
+ if (built)
489
+ return;
490
+ built = true;
491
+ const outer = sink;
492
+ sink = own;
493
+ try {
494
+ // A tab's parts sit where the tabs do: on the page, or in the box round them.
495
+ panel.append(grid(tab.children, 1, { columns: 1, onPage: place.onPage, labels: place.labels }));
496
+ }
497
+ finally {
498
+ sink = outer;
499
+ }
500
+ };
501
+ const open = () => !form.node(tab.id).invisible && (show(tab.id), true);
338
502
  button.addEventListener('click', () => show(tab.id));
339
503
  if (!tabs.has(tab.id))
340
- tabs.set(tab.id, () => !form.node(tab.id).invisible && (show(tab.id), true));
504
+ tabs.set(tab.id, open);
505
+ // The fields it holds, to mark it while one has a problem and to open it at one.
506
+ const fields = fieldsIn(tab.children);
507
+ if (scope.root)
508
+ for (const name of fields)
509
+ if (!problemTabs.has(name))
510
+ problemTabs.set(name, open);
341
511
  list.append(button);
342
- return { tab, button, panel };
512
+ return { tab, button, panel, own, build, fields };
343
513
  });
344
514
  list.addEventListener('keydown', (event) => {
345
515
  const shown = parts.filter((p) => !p.button.hidden);
@@ -353,33 +523,76 @@ function mountViewer(host, options) {
353
523
  show(next.tab.id);
354
524
  next.button.focus();
355
525
  });
356
- box.append(list, ...parts.map((p) => p.panel));
357
- watch(() => {
358
- box.hidden = form.node(node.id).invisible;
526
+ // Too long for its room, the strip scrolls; the tab chosen, however, comes into sight in it.
527
+ const strip = (0, tab_strip_1.tabStrip)(list, el);
528
+ cleanups.push(strip.destroy);
529
+ box.append(strip.element, ...parts.map((p) => p.panel));
530
+ let shownKey = '';
531
+ let revealed = '';
532
+ watch((state) => {
533
+ const tabsHidden = form.node(node.id).invisible;
534
+ (0, dom_1.setHidden)(box, tabsHidden);
359
535
  const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
360
536
  const keep = picked !== null && shown.some((p) => p.tab.id === picked);
361
537
  if (keep)
362
538
  active = picked;
363
539
  else if (shown.length)
364
540
  active = shown[0].tab.id;
541
+ const errors = form.getState().errors;
365
542
  for (const p of parts) {
366
543
  const selected = p.tab.id === active;
367
- p.button.hidden = !shown.includes(p);
368
- p.button.setAttribute('aria-selected', String(selected));
369
- p.button.tabIndex = selected ? 0 : -1;
370
- p.panel.hidden = !selected;
544
+ (0, dom_1.setHidden)(p.button, !shown.includes(p));
545
+ (0, dom_1.setAttr)(p.button, 'aria-selected', String(selected));
546
+ if (p.button.tabIndex !== (selected ? 0 : -1))
547
+ p.button.tabIndex = selected ? 0 : -1;
548
+ (0, dom_1.setAttr)(p.button, 'data-problem', p.fields.some((name) => errors[name]) ? '' : null);
549
+ (0, dom_1.setHidden)(p.panel, !selected);
550
+ // The tab shown: drawn if it is new, and brought up to date — it was left alone while out of sight.
551
+ if (selected && !tabsHidden) {
552
+ p.build();
553
+ for (const update of p.own)
554
+ update(state);
555
+ }
556
+ }
557
+ // Measured only when it can have changed: the strip's tabs, or the one chosen.
558
+ const key = shown.map((p) => p.tab.id).join(' ');
559
+ if (key !== shownKey) {
560
+ shownKey = key;
561
+ strip.measure();
562
+ }
563
+ if (active !== revealed && list.clientWidth) {
564
+ revealed = active;
565
+ const chosen = parts.find((p) => p.tab.id === active);
566
+ if (chosen)
567
+ strip.reveal(chosen.button);
371
568
  }
372
569
  });
373
570
  return box;
374
571
  }
572
+ /** The fields a tab holds, in its sections and tabs inside it; a saved form placed in it keeps its own. */
573
+ function fieldsIn(children) {
574
+ return children.flatMap((child) => child.type === 'field' ? [child.field]
575
+ : child.type === 'section' ? fieldsIn(child.children)
576
+ : child.type === 'tabs' ? child.children.flatMap((tab) => fieldsIn(tab.children))
577
+ : []);
578
+ }
375
579
  function item(node, place) {
580
+ return hiddenOn(node, partItem(node, place));
581
+ }
582
+ /** A part hidden at some widths of the form (`hideOn`), as a phone's: the stylesheet hides it there. */
583
+ function hiddenOn(node, element) {
584
+ if (node.hideOn?.length)
585
+ element.dataset['hideOn'] = node.hideOn.join(' ');
586
+ return element;
587
+ }
588
+ function partItem(node, place) {
376
589
  switch (node.type) {
377
590
  case 'field':
378
591
  return fieldItem(node, place.labels);
379
592
  case 'button':
380
593
  return buttonItem(node);
381
594
  case 'text':
382
- return textItem(node);
595
+ return textItem(node, place.inline);
383
596
  case 'slot':
384
597
  return slotItem(node);
385
598
  case 'section':
@@ -526,9 +739,12 @@ function mountViewer(host, options) {
526
739
  else
527
740
  locked = false;
528
741
  root.toggleAttribute('data-readonly', locked);
742
+ // Conditions read `editing`: parts for editing only, or reading only, follow.
743
+ form.setEditing(!locked);
529
744
  render(form.getState());
530
745
  });
531
746
  root.toggleAttribute('data-readonly', locked);
747
+ form.setEditing(!locked);
532
748
  updaters.push(() => {
533
749
  if (!editSwitch)
534
750
  return;
@@ -626,7 +842,15 @@ function mountViewer(host, options) {
626
842
  draftText.textContent = fill(labels.draftFound, { time: new Date(state.draft.savedAt).toLocaleString() });
627
843
  });
628
844
  function focusFirstProblem() {
629
- const invalid = root.querySelector('[aria-invalid="true"]');
845
+ // A problem in a tab out of sight, or not drawn yet: its tab shown first, the first such in the page's order.
846
+ const seen = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]'));
847
+ if (!seen) {
848
+ const errors = form.getState().errors;
849
+ const first = [...problemTabs.keys()].find((name) => errors[name]);
850
+ if (first)
851
+ problemTabs.get(first)?.();
852
+ }
853
+ const invalid = [...root.querySelectorAll('[aria-invalid="true"]')].find((e) => !e.closest('.fd-tabpanel[hidden]')) ?? null;
630
854
  if (!invalid)
631
855
  return;
632
856
  for (let folded = invalid.closest('.fd-section-folded'); folded; folded = folded.parentElement?.closest('.fd-section-folded') ?? null) {
@@ -661,6 +885,52 @@ function mountViewer(host, options) {
661
885
  content.hidden = false;
662
886
  done.hidden = true;
663
887
  });
888
+ /** The record's name, as the last of its breadcrumbs says it: its sheet's title field, else the page's title; empty for a new one. */
889
+ function recordName() {
890
+ const state = form.getState();
891
+ if (state.recordId == null)
892
+ return '';
893
+ const field = page.layout.type === 'sheet' ? page.layout.title?.field : undefined;
894
+ const name = field ? (0, widgets_1.displayValue)(page.fields[field], state.values[field], state.values, locale) : '';
895
+ return name || page.title || '';
896
+ }
897
+ /** The bar over a record — its breadcrumbs, its gear menu, its pager — or null when it would hold nothing. */
898
+ function topBar(toolbar) {
899
+ if (page.data.kind !== 'record')
900
+ return null;
901
+ const bar = (0, record_bar_1.recordBar)({
902
+ doc,
903
+ el,
904
+ form,
905
+ toolbar,
906
+ records: options.records,
907
+ breadcrumbs: options.breadcrumbs,
908
+ labels,
909
+ icons: options.icons,
910
+ uid,
911
+ fill,
912
+ withIcon,
913
+ press,
914
+ name: recordName,
915
+ render: renderNow,
916
+ async leave() {
917
+ if (!form.getState().dirty.length)
918
+ return true;
919
+ const saved = await form.save();
920
+ if (!saved) {
921
+ if (!form.getState().saveProblem)
922
+ announce(checkText(form.getState()));
923
+ focusFirstProblem();
924
+ }
925
+ return saved;
926
+ },
927
+ });
928
+ if (!bar)
929
+ return null;
930
+ updaters.push(bar.update);
931
+ cleanups.push(bar.destroy);
932
+ return bar.element;
933
+ }
664
934
  function pageHead() {
665
935
  if (!page.title && !page.description)
666
936
  return null;
@@ -672,8 +942,20 @@ function mountViewer(host, options) {
672
942
  return head;
673
943
  }
674
944
  // ---- the four page layouts ---------------------------------------------------
945
+ /**
946
+ * A page's own buttons at its foot, as a wizard's, in place of its Save and
947
+ * Discard; null when it has none, or when a dialog round it draws them.
948
+ */
949
+ function pageFooter(footer) {
950
+ if (!footer?.length || options.showActions === false)
951
+ return null;
952
+ return el('div', { class: 'fd-actions fd-actions-end fd-page-footer' }, status, ...footer.map(buttonItem));
953
+ }
675
954
  function sectionsLayout(node) {
676
955
  const box = el('div', { class: 'fd-sections', 'data-node': node.id }, ...node.children.map((child) => item(child, top(true))));
956
+ const own = pageFooter(node.footer);
957
+ if (own)
958
+ return el('div', {}, box, own);
677
959
  const action = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, page.data.kind === 'responses' ? labels.submit : labels.save);
678
960
  const actions = el('div', { class: 'fd-actions fd-actions-end' }, status, action);
679
961
  if (page.data.kind === 'record') {
@@ -792,7 +1074,8 @@ function mountViewer(host, options) {
792
1074
  // Save and Discard lead the header bar, or close the sheet when the page puts them at its foot.
793
1075
  const atFoot = page.actionsPosition === 'bottom';
794
1076
  const foot = el('div', { class: 'fd-actions fd-actions-end fd-sheet-foot' });
795
- if (options.showActions === false)
1077
+ const own = pageFooter(node.footer);
1078
+ if (options.showActions === false || own)
796
1079
  actions.append(...(node.buttons ?? []).map(buttonItem));
797
1080
  else if (atFoot) {
798
1081
  actions.append(...(node.buttons ?? []).map(buttonItem));
@@ -813,31 +1096,89 @@ function mountViewer(host, options) {
813
1096
  if (node.statusbar && !underTitle)
814
1097
  header.append(statusbar(node.statusbar));
815
1098
  const card = el('div', { class: 'fd-card' });
816
- if (node.ribbon) {
817
- const ribbon = el('div', { class: `fd-ribbon fd-tone-${node.ribbon.tone ?? 'muted'}`, 'data-node': node.ribbon.id }, node.ribbon.label);
818
- hideWhen(ribbon, node.ribbon.id);
819
- // The ribbon is clipped to the card's corner by its own frame, so the card itself never clips: lists open past it.
820
- card.append(el('div', { class: 'fd-ribbon-frame' }, ribbon));
1099
+ const ribbons = [...(node.ribbon ? [node.ribbon] : []), ...(node.ribbons ?? [])];
1100
+ if (ribbons.length) {
1101
+ // The ribbons are clipped to the card's corner by their own frame, so the card itself never clips: lists open past it.
1102
+ const frame = el('div', { class: 'fd-ribbon-frame' });
1103
+ const drawn = ribbons.map((ribbon) => {
1104
+ const element = el('div', { class: `fd-ribbon fd-tone-${ribbon.tone ?? 'muted'}`, 'data-node': ribbon.id, title: ribbon.tooltip }, ribbon.label);
1105
+ frame.append(element);
1106
+ return { ribbon, element };
1107
+ });
1108
+ // One corner, one ribbon: the first whose condition holds; its words from a field when the field holds any.
1109
+ updaters.push((state) => {
1110
+ let shown = false;
1111
+ for (const { ribbon, element } of drawn) {
1112
+ const show = !shown && !form.node(ribbon.id).invisible;
1113
+ shown ||= show;
1114
+ (0, dom_1.setHidden)(element, !show);
1115
+ if (!show)
1116
+ continue;
1117
+ const from = ribbon.labelField ? (0, widgets_1.displayValue)(page.fields[ribbon.labelField], state.values[ribbon.labelField], state.values, locale) : '';
1118
+ (0, dom_1.setText)(element, from || ribbon.label);
1119
+ }
1120
+ });
1121
+ card.append(frame);
821
1122
  }
822
1123
  if (node.statButtons?.length) {
823
1124
  const stats = el('div', { class: 'fd-stats' });
1125
+ /** A field's value as the field shows it: money with its currency, a date, a count grouped. */
1126
+ const shownValue = (name, values) => name && page.fields[name] ? (0, widgets_1.displayValue)(page.fields[name], values[name], values, locale) : '';
824
1127
  for (const stat of node.statButtons) {
825
1128
  const value = el('span', { class: 'fd-stat-value' });
1129
+ const label = el('span', { class: 'fd-stat-label' }, stat.label);
1130
+ // A second value with words of its own: the two one over the other, each after its words.
1131
+ const second = stat.secondField && stat.secondLabel !== undefined ? { value: el('span', { class: 'fd-stat-value' }), label: el('span', { class: 'fd-stat-label' }, stat.secondLabel) } : null;
826
1132
  const icon = (0, widgets_1.drawIcon)(doc, stat.icon, options.icons);
827
- const words = el('span', { class: 'fd-stat-words' }, value, el('span', { class: 'fd-stat-label' }, stat.label));
828
- const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id }, ...(icon ? [icon, words] : [words]));
1133
+ const words = second
1134
+ ? el('span', { class: 'fd-stat-words fd-stat-pair' }, el('span', { class: 'fd-stat-row' }, label, value), el('span', { class: 'fd-stat-row' }, second.label, second.value))
1135
+ : el('span', { class: 'fd-stat-words' }, value, label);
1136
+ // Its help, as Flectra's help=: its tooltip, and its description for a screen reader — never its name.
1137
+ const helpId = stat.help ? uid(`${stat.id}-help`) : undefined;
1138
+ const button = el('button', { type: 'button', class: 'fd-stat', 'data-node': stat.id, title: stat.help || undefined, 'aria-describedby': helpId }, ...(icon ? [icon, words] : [words]));
1139
+ if (helpId)
1140
+ stats.append(el('span', { class: 'fd-sr-only', id: helpId }, stat.help));
829
1141
  button.addEventListener('click', () => void press(button, () => form.runAction(stat.id)));
830
1142
  updaters.push((state) => {
831
1143
  button.hidden = form.node(stat.id).invisible;
832
- const count = stat.field ? state.values[stat.field] : null;
833
- value.textContent = count === null || count === undefined ? '' : String(count);
1144
+ const values = state.values;
1145
+ const unit = shownValue(stat.unitField, values) || stat.unit || '';
1146
+ const first = shownValue(stat.field, values);
1147
+ const other = shownValue(stat.secondField, values);
1148
+ if (second) {
1149
+ (0, dom_1.setText)(value, first);
1150
+ (0, dom_1.setText)(second.value, [other, unit].filter(Boolean).join(' '));
1151
+ }
1152
+ else
1153
+ (0, dom_1.setText)(value, [[first, other].filter(Boolean).join(' / '), first || other ? unit : ''].filter(Boolean).join(' '));
1154
+ (0, dom_1.setText)(label, shownValue(stat.labelField, values) || stat.label);
834
1155
  });
835
1156
  stats.append(button);
836
1157
  }
1158
+ // No empty row while every button is hidden, as with the badges.
1159
+ updaters.push(() => {
1160
+ stats.hidden = [...stats.querySelectorAll(':scope > .fd-stat')].every((stat) => stat.hidden);
1161
+ });
837
1162
  card.append(stats);
838
1163
  }
839
1164
  for (const alert of node.alerts ?? []) {
840
- 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));
1165
+ // Its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled.
1166
+ const words = (0, value_words_1.valueWords)(doc, page.fields, alert.message, locale);
1167
+ const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
1168
+ let fromField = false;
1169
+ const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
1170
+ if (alert.buttons?.length)
1171
+ box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
1172
+ updaters.push((state) => {
1173
+ words.update(state.values);
1174
+ const from = alert.messageField ? state.values[alert.messageField] : null;
1175
+ const said = typeof from === 'string' ? from.trim() : '';
1176
+ if (said)
1177
+ (0, dom_1.setText)(message, said);
1178
+ else if (fromField)
1179
+ message.replaceChildren(...words.nodes);
1180
+ fromField = !!said;
1181
+ });
841
1182
  if (alert.dismissible) {
842
1183
  // Closed, it stays closed while the page is open, whatever its condition does.
843
1184
  let dismissed = false;
@@ -869,9 +1210,26 @@ function mountViewer(host, options) {
869
1210
  }
870
1211
  if (node.title) {
871
1212
  const title = el('div', { class: 'fd-title' });
1213
+ // Words over the title, as Flectra's "Product Name" over its h1, naming its box.
1214
+ if (node.title.label)
1215
+ title.append(el('label', { class: 'fd-title-label', for: uid('#title') }, node.title.label));
872
1216
  if (node.title.above?.length)
873
1217
  title.append(el('div', { class: 'fd-title-above' }, ...node.title.above.map((part) => fieldItem(part))));
874
- title.append(fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder }));
1218
+ const name = fieldItem({ type: 'field', id: '#title', field: node.title.field, placeholder: node.title.placeholder, ...(node.title.placeholderWhen ? { placeholderWhen: node.title.placeholderWhen } : {}), ...(node.title.focus ? { focus: true } : {}) });
1219
+ // Fields on the title's line, before and after it: a priority star, a state's dot (Flectra's <h1>).
1220
+ const before = node.title.before ?? [];
1221
+ const after = node.title.after ?? [];
1222
+ if (before.length || after.length) {
1223
+ name.classList.add('fd-title-name');
1224
+ const side = (part) => {
1225
+ const item = fieldItem(part);
1226
+ item.classList.add('fd-title-side');
1227
+ return item;
1228
+ };
1229
+ title.append(el('div', { class: 'fd-title-line' }, ...before.map(side), name, ...after.map(side)));
1230
+ }
1231
+ else
1232
+ title.append(name);
875
1233
  if (node.title.subtitleField)
876
1234
  title.append(fieldItem({ type: 'field', id: '#subtitle', field: node.title.subtitleField }));
877
1235
  if (node.title.below?.length)
@@ -887,6 +1245,8 @@ function mountViewer(host, options) {
887
1245
  if (node.statusbar && underTitle)
888
1246
  card.append(el('div', { class: 'fd-title-statusbar' }, statusbar(node.statusbar)));
889
1247
  card.append(grid(node.children, 1, top(false)));
1248
+ if (own)
1249
+ card.append(own);
890
1250
  if (atFoot && options.showActions !== false) {
891
1251
  card.append(foot);
892
1252
  updaters.push((state) => {
@@ -894,11 +1254,22 @@ function mountViewer(host, options) {
894
1254
  });
895
1255
  }
896
1256
  const layout = el('div', { class: 'fd-sheet-layout' }, card);
1257
+ if (node.attachmentPreview) {
1258
+ // Beside the sheet on a wide form, the side panel then under the sheet; under the sheet on a narrow one.
1259
+ const preview = (0, attachment_preview_1.attachmentPreview)({ doc, el, form, page, node: node.attachmentPreview, dataSource: options.dataSource, labels, fill });
1260
+ layout.classList.add('fd-has-preview');
1261
+ layout.append(preview.element);
1262
+ updaters.push(preview.update);
1263
+ cleanups.push(preview.destroy);
1264
+ }
897
1265
  if (node.sidePanel) {
898
1266
  layout.classList.add('fd-has-side');
1267
+ if (node.sidePanelBeside)
1268
+ layout.setAttribute('data-side-beside', node.sidePanelBeside);
899
1269
  layout.append(el('aside', { class: 'fd-side' }, slotItem(node.sidePanel)));
900
1270
  }
901
- pageBox.append(header, layout);
1271
+ const bar = topBar(node.toolbar);
1272
+ pageBox.append(...(bar ? [bar] : []), header, layout);
902
1273
  return pageBox;
903
1274
  }
904
1275
  /** The sheet header's statusbar: the same widget a form can place anywhere. */
@@ -907,9 +1278,18 @@ function mountViewer(host, options) {
907
1278
  const barOptions = { clickable: config.clickable === true };
908
1279
  if (config.visibleStates)
909
1280
  barOptions['visibleStates'] = config.visibleStates;
1281
+ for (const key of ['durationsField', 'fold', 'saves'])
1282
+ if (config[key] !== undefined)
1283
+ barOptions[key] = config[key];
910
1284
  const node = { type: 'field', id: '#statusbar', field: config.field, widget: 'statusbar', options: barOptions };
911
1285
  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);
912
- updaters.push((state) => widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false }));
1286
+ if (widget.destroy)
1287
+ cleanups.push(() => widget.destroy?.());
1288
+ updaters.push((state) => {
1289
+ // Hidden while its own condition holds, or from people outside its roles.
1290
+ (0, dom_1.setHidden)(widget.element, form.node('#statusbar').invisible);
1291
+ widget.update({ value: state.values[config.field], values: state.values, readonly: form.node('#statusbar').readonly || locked, required: false, invalid: false });
1292
+ });
913
1293
  return widget.element;
914
1294
  }
915
1295
  let wizardForward = null;
@@ -938,6 +1318,7 @@ function mountViewer(host, options) {
938
1318
  icons: options.icons,
939
1319
  preferences,
940
1320
  onOpenRecord: options.onOpenRecord,
1321
+ ...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
941
1322
  });
942
1323
  cleanups.push(list.destroy);
943
1324
  body = list.element;
@@ -945,7 +1326,9 @@ function mountViewer(host, options) {
945
1326
  else
946
1327
  body = sectionsLayout(layout);
947
1328
  const head = layout.type === 'sheet' ? null : pageHead();
948
- content.append(...(head ? [head] : []), banner, draft, body);
1329
+ // A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
1330
+ const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
1331
+ content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
949
1332
  root.append(content, done, announcer);
950
1333
  // The status leaves its place beside Save when the page wants it in a corner or across the top.
951
1334
  if (options.saveStatus === 'toast') {
@@ -1020,6 +1403,46 @@ function mountViewer(host, options) {
1020
1403
  event.preventDefault();
1021
1404
  nextField(from)?.focus();
1022
1405
  });
1406
+ // ---- a button's key, with Alt -------------------------------------------------
1407
+ /**
1408
+ * Whether a key pressed with Alt is this form's: pressed in it (not in a
1409
+ * form inside it, such as a dialog's), or — pressed outside any form — this
1410
+ * is the first form on the page, or in the question or dialog on top of it.
1411
+ */
1412
+ function hotkeysHere(target) {
1413
+ const inner = target instanceof Element ? target.closest('.fd-form') : null;
1414
+ if (inner)
1415
+ return inner === root && !root.querySelector(':scope > .fd-dialog-backdrop');
1416
+ const modals = [...doc.querySelectorAll('[aria-modal="true"]')].filter((modal) => !modal.closest('[hidden]'));
1417
+ const top = modals[modals.length - 1];
1418
+ return (top ?? doc).querySelector('.fd-form') === root;
1419
+ }
1420
+ const onHotkey = (event) => {
1421
+ if (event.key === 'Alt') {
1422
+ if (hotkeysHere(event.target))
1423
+ root.setAttribute('data-hotkeys', '');
1424
+ return;
1425
+ }
1426
+ const key = (0, hotkeys_1.hotkeyOf)(event);
1427
+ if (!key || !hotkeysHere(event.target))
1428
+ return;
1429
+ const button = [...root.querySelectorAll(`button[data-hotkey="${key}"]`)].find((b) => b.closest('.fd-form') === root && !b.closest('[hidden]') && !b.disabled && !b.hasAttribute('aria-busy'));
1430
+ if (!button)
1431
+ return;
1432
+ event.preventDefault();
1433
+ root.removeAttribute('data-hotkeys');
1434
+ button.click();
1435
+ };
1436
+ const offHotkeys = () => root.removeAttribute('data-hotkeys');
1437
+ const altUp = (event) => event.key === 'Alt' && offHotkeys();
1438
+ doc.addEventListener('keydown', onHotkey);
1439
+ doc.addEventListener('keyup', altUp);
1440
+ doc.defaultView?.addEventListener('blur', offHotkeys);
1441
+ cleanups.push(() => {
1442
+ doc.removeEventListener('keydown', onHotkey);
1443
+ doc.removeEventListener('keyup', altUp);
1444
+ doc.defaultView?.removeEventListener('blur', offHotkeys);
1445
+ });
1023
1446
  // ---- confirmation dialog -----------------------------------------------------
1024
1447
  /** A question with Cancel and OK, or with `withCancel` false a notice with OK alone. */
1025
1448
  function dialogConfirm(message, withCancel = true) {
@@ -1069,6 +1492,24 @@ function mountViewer(host, options) {
1069
1492
  }));
1070
1493
  render(form.getState());
1071
1494
  mounted = true;
1495
+ // As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
1496
+ // only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
1497
+ let opened = false;
1498
+ if (focusers.length) {
1499
+ cleanups.push(form.on('open', () => {
1500
+ const first = !opened;
1501
+ opened = true;
1502
+ queueMicrotask(() => {
1503
+ if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
1504
+ return;
1505
+ const active = doc.activeElement;
1506
+ const free = !active || active === doc.body || active === doc.documentElement;
1507
+ if (!free && !(first && root.contains(active)))
1508
+ return;
1509
+ focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
1510
+ });
1511
+ }));
1512
+ }
1072
1513
  if (form.getState().status === 'idle')
1073
1514
  void form.load();
1074
1515
  return {
@@ -1092,6 +1533,7 @@ function mountViewer(host, options) {
1092
1533
  setReadonly(readonly) {
1093
1534
  locked = readonly;
1094
1535
  root.toggleAttribute('data-readonly', locked);
1536
+ form.setEditing(!locked);
1095
1537
  render(form.getState());
1096
1538
  },
1097
1539
  isReadonly: () => locked,