@fieldia/viewer 0.19.1 → 0.20.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 (158) hide show
  1. package/README.md +18 -0
  2. package/bundle/fieldia.ar.js +2 -2
  3. package/bundle/fieldia.ar.js.map +2 -2
  4. package/bundle/fieldia.choice.js +5 -0
  5. package/bundle/fieldia.choice.js.map +7 -0
  6. package/bundle/fieldia.de.js +2 -2
  7. package/bundle/fieldia.de.js.map +2 -2
  8. package/bundle/fieldia.filters.js +5 -0
  9. package/bundle/fieldia.filters.js.map +7 -0
  10. package/bundle/fieldia.fr.js +2 -2
  11. package/bundle/fieldia.fr.js.map +2 -2
  12. package/bundle/fieldia.head.js +5 -0
  13. package/bundle/fieldia.head.js.map +7 -0
  14. package/bundle/fieldia.js +44 -44
  15. package/bundle/fieldia.js.map +4 -4
  16. package/bundle/fieldia.path.js +5 -0
  17. package/bundle/fieldia.path.js.map +7 -0
  18. package/bundle/fieldia.text.js +24 -0
  19. package/bundle/fieldia.text.js.map +7 -0
  20. package/package.json +7 -3
  21. package/src/index.d.mts +2 -1
  22. package/src/index.d.ts +2 -1
  23. package/src/index.js +6 -1
  24. package/src/index.js.map +1 -1
  25. package/src/index.mjs +1 -0
  26. package/src/index.mjs.map +1 -1
  27. package/src/lib/button-parts.d.mts +30 -0
  28. package/src/lib/button-parts.d.ts +30 -0
  29. package/src/lib/button-parts.js +24 -0
  30. package/src/lib/button-parts.js.map +1 -0
  31. package/src/lib/button-parts.mjs +18 -0
  32. package/src/lib/button-parts.mjs.map +1 -0
  33. package/src/lib/dialog-window.d.mts +11 -0
  34. package/src/lib/dialog-window.d.ts +11 -0
  35. package/src/lib/dialog-window.js +86 -0
  36. package/src/lib/dialog-window.js.map +1 -0
  37. package/src/lib/dialog-window.mjs +82 -0
  38. package/src/lib/dialog-window.mjs.map +1 -0
  39. package/src/lib/dialog.d.mts +14 -3
  40. package/src/lib/dialog.d.ts +14 -3
  41. package/src/lib/dialog.js +84 -14
  42. package/src/lib/dialog.js.map +1 -1
  43. package/src/lib/dialog.mjs +85 -15
  44. package/src/lib/dialog.mjs.map +1 -1
  45. package/src/lib/filter-bar.d.mts +39 -0
  46. package/src/lib/filter-bar.d.ts +39 -0
  47. package/src/lib/filter-bar.js +290 -0
  48. package/src/lib/filter-bar.js.map +1 -0
  49. package/src/lib/filter-bar.mjs +287 -0
  50. package/src/lib/filter-bar.mjs.map +1 -0
  51. package/src/lib/filter-styles.d.mts +4 -0
  52. package/src/lib/filter-styles.d.ts +4 -0
  53. package/src/lib/filter-styles.js +63 -0
  54. package/src/lib/filter-styles.js.map +1 -0
  55. package/src/lib/filter-styles.mjs +59 -0
  56. package/src/lib/filter-styles.mjs.map +1 -0
  57. package/src/lib/filters-part.d.mts +8 -0
  58. package/src/lib/filters-part.d.ts +8 -0
  59. package/src/lib/filters-part.js +14 -0
  60. package/src/lib/filters-part.js.map +1 -0
  61. package/src/lib/filters-part.mjs +9 -0
  62. package/src/lib/filters-part.mjs.map +1 -0
  63. package/src/lib/group-styles.d.mts +9 -0
  64. package/src/lib/group-styles.d.ts +9 -0
  65. package/src/lib/group-styles.js +64 -0
  66. package/src/lib/group-styles.js.map +1 -0
  67. package/src/lib/group-styles.mjs +60 -0
  68. package/src/lib/group-styles.mjs.map +1 -0
  69. package/src/lib/head-part.d.mts +15 -0
  70. package/src/lib/head-part.d.ts +15 -0
  71. package/src/lib/head-part.js +26 -0
  72. package/src/lib/head-part.js.map +1 -0
  73. package/src/lib/head-part.mjs +16 -0
  74. package/src/lib/head-part.mjs.map +1 -0
  75. package/src/lib/head-styles.d.mts +28 -0
  76. package/src/lib/head-styles.d.ts +28 -0
  77. package/src/lib/head-styles.js +320 -0
  78. package/src/lib/head-styles.js.map +1 -0
  79. package/src/lib/head-styles.mjs +316 -0
  80. package/src/lib/head-styles.mjs.map +1 -0
  81. package/src/lib/labels.d.mts +29 -0
  82. package/src/lib/labels.d.ts +29 -0
  83. package/src/lib/labels.js +16 -0
  84. package/src/lib/labels.js.map +1 -1
  85. package/src/lib/labels.mjs +16 -0
  86. package/src/lib/labels.mjs.map +1 -1
  87. package/src/lib/list-styles.d.mts +1 -1
  88. package/src/lib/list-styles.d.ts +1 -1
  89. package/src/lib/list-styles.js +88 -1
  90. package/src/lib/list-styles.js.map +1 -1
  91. package/src/lib/list-styles.mjs +88 -1
  92. package/src/lib/list-styles.mjs.map +1 -1
  93. package/src/lib/list.d.mts +16 -1
  94. package/src/lib/list.d.ts +16 -1
  95. package/src/lib/list.js +281 -51
  96. package/src/lib/list.js.map +1 -1
  97. package/src/lib/list.mjs +282 -52
  98. package/src/lib/list.mjs.map +1 -1
  99. package/src/lib/locales/ar.js +16 -0
  100. package/src/lib/locales/ar.js.map +1 -1
  101. package/src/lib/locales/ar.mjs +16 -0
  102. package/src/lib/locales/ar.mjs.map +1 -1
  103. package/src/lib/locales/de.js +16 -0
  104. package/src/lib/locales/de.js.map +1 -1
  105. package/src/lib/locales/de.mjs +16 -0
  106. package/src/lib/locales/de.mjs.map +1 -1
  107. package/src/lib/locales/fr.js +16 -0
  108. package/src/lib/locales/fr.js.map +1 -1
  109. package/src/lib/locales/fr.mjs +16 -0
  110. package/src/lib/locales/fr.mjs.map +1 -1
  111. package/src/lib/look.d.mts +1 -1
  112. package/src/lib/look.d.ts +1 -1
  113. package/src/lib/look.js +3 -0
  114. package/src/lib/look.js.map +1 -1
  115. package/src/lib/look.mjs +3 -0
  116. package/src/lib/look.mjs.map +1 -1
  117. package/src/lib/record-bar.d.mts +10 -1
  118. package/src/lib/record-bar.d.ts +10 -1
  119. package/src/lib/record-bar.js +15 -7
  120. package/src/lib/record-bar.js.map +1 -1
  121. package/src/lib/record-bar.mjs +14 -7
  122. package/src/lib/record-bar.mjs.map +1 -1
  123. package/src/lib/record-head.d.mts +58 -0
  124. package/src/lib/record-head.d.ts +58 -0
  125. package/src/lib/record-head.js +245 -0
  126. package/src/lib/record-head.js.map +1 -0
  127. package/src/lib/record-head.mjs +240 -0
  128. package/src/lib/record-head.mjs.map +1 -0
  129. package/src/lib/rich-button.d.mts +12 -0
  130. package/src/lib/rich-button.d.ts +12 -0
  131. package/src/lib/rich-button.js +186 -0
  132. package/src/lib/rich-button.js.map +1 -0
  133. package/src/lib/rich-button.mjs +183 -0
  134. package/src/lib/rich-button.mjs.map +1 -0
  135. package/src/lib/section-head.d.mts +26 -0
  136. package/src/lib/section-head.d.ts +26 -0
  137. package/src/lib/section-head.js +65 -0
  138. package/src/lib/section-head.js.map +1 -0
  139. package/src/lib/section-head.mjs +56 -0
  140. package/src/lib/section-head.mjs.map +1 -0
  141. package/src/lib/section-tools.d.mts +12 -0
  142. package/src/lib/section-tools.d.ts +12 -0
  143. package/src/lib/section-tools.js +124 -0
  144. package/src/lib/section-tools.js.map +1 -0
  145. package/src/lib/section-tools.mjs +121 -0
  146. package/src/lib/section-tools.mjs.map +1 -0
  147. package/src/lib/viewer-parts.d.mts +33 -0
  148. package/src/lib/viewer-parts.d.ts +33 -0
  149. package/src/lib/viewer-parts.js +75 -0
  150. package/src/lib/viewer-parts.js.map +1 -0
  151. package/src/lib/viewer-parts.mjs +70 -0
  152. package/src/lib/viewer-parts.mjs.map +1 -0
  153. package/src/lib/viewer.d.mts +30 -7
  154. package/src/lib/viewer.d.ts +30 -7
  155. package/src/lib/viewer.js +317 -97
  156. package/src/lib/viewer.js.map +1 -1
  157. package/src/lib/viewer.mjs +319 -99
  158. package/src/lib/viewer.mjs.map +1 -1
package/src/lib/viewer.js CHANGED
@@ -18,7 +18,10 @@ const value_words_1 = require("./value-words");
18
18
  const hotkeys_1 = require("./hotkeys");
19
19
  const record_bar_1 = require("./record-bar");
20
20
  const attachment_preview_1 = require("./attachment-preview");
21
+ const section_head_1 = require("./section-head");
21
22
  const record_styles_1 = require("./record-styles");
23
+ const button_parts_1 = require("./button-parts");
24
+ const viewer_parts_1 = require("./viewer-parts");
22
25
  const labels_1 = require("./labels");
23
26
  const focus_trap_1 = require("./focus-trap");
24
27
  var labels_2 = require("./labels");
@@ -118,6 +121,31 @@ function mountViewer(host, given) {
118
121
  const words = el('bdi', {}, text);
119
122
  return icon ? [icon, words] : [words];
120
123
  }
124
+ /**
125
+ * A tab's face: its icon and words, or its icon alone (its words then its
126
+ * name and tooltip), and the pill of its count after them.
127
+ */
128
+ function tabFace(tab) {
129
+ const pill = tab.count !== undefined ? [el('span', { class: 'fd-tab-count', 'aria-hidden': 'true' }, '0')] : [];
130
+ if (!tab.iconOnly)
131
+ return [...withIcon(tab.icon, tab.label), ...pill];
132
+ const icon = (0, widgets_1.drawIcon)(doc, tab.icon, options.icons);
133
+ return [...(icon ? [icon] : []), ...pill];
134
+ }
135
+ /**
136
+ * A label's words with its access key's letter underlined, as a work item's
137
+ * "Stat_e_": the first of that letter in them, whatever its case. Its words
138
+ * stay one piece for whatever reads them; without a key, the words alone.
139
+ */
140
+ function accessKeyed(text, key) {
141
+ const words = keepMarkWithWord(text);
142
+ const at = key ? words.toLocaleLowerCase().indexOf(key) : -1;
143
+ if (at === -1)
144
+ return [words];
145
+ return [el('span', { class: 'fd-label-words' }, words.slice(0, at), el('span', { class: 'fd-access-key' }, words.slice(at, at + 1)), words.slice(at + 1))];
146
+ }
147
+ /** What a group's heading and a help box draw with. */
148
+ const headKit = { doc, labels: widgetLabels, icons: options.icons, withIcon, el };
121
149
  const theme = options.theme ?? page.look?.theme;
122
150
  const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': (0, look_1.skinFor)(options.skin ?? page.look?.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
123
151
  (0, look_1.applyLook)(root, theme ? { ...page.look, theme } : page.look);
@@ -154,15 +182,21 @@ function mountViewer(host, given) {
154
182
  // ---- the parts -------------------------------------------------------
155
183
  /** The fields that take the focus as the record opens, in the order they are drawn. */
156
184
  const focusers = [];
185
+ /** The fields with an access key, in the order they are drawn: Alt with the key focuses the first shown. */
186
+ const fieldKeys = [];
157
187
  /** Each field's face, put on again when the form changes skin: quiet, or in its box. */
158
188
  const faces = [];
159
189
  /** Folded sections, and how to open each: a problem inside one has to be seen. */
160
190
  const folds = new Map();
161
191
  /** Every part drawn again from the state of the page shown. */
162
192
  const renderNow = () => render(form.getState());
193
+ /** The record's head being drawn: tabs placed in it go to its band. */
194
+ let headNow = null;
163
195
  /** Set once the viewer is on the page; set again when it is taken away. */
164
196
  let mounted = false;
165
197
  let gone = false;
198
+ /** Whether the form has opened a record yet (its `open` event). */
199
+ let opened = false;
166
200
  /** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
167
201
  function spans(element, colspan) {
168
202
  if (colspan)
@@ -191,7 +225,7 @@ function mountViewer(host, given) {
191
225
  if (node.colspan)
192
226
  wrapper.style.setProperty('--fd-span', String(node.colspan));
193
227
  const labelText = node.label ?? def.label;
194
- const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, keepMarkWithWord(labelText));
228
+ const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, ...accessKeyed(labelText, node.hotkey));
195
229
  // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
196
230
  const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? (0, widgets_1.drawIcon)(doc, 'check') : null;
197
231
  if (mark) {
@@ -199,9 +233,17 @@ function mountViewer(host, given) {
199
233
  label.append(mark);
200
234
  }
201
235
  const widget = (0, widgets_1.createWidget)({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
236
+ // Its own tools (a Markdown text's Edit and Done), for a group that holds it alone to put in its heading.
237
+ (0, section_head_1.keepTools)(wrapper, widget);
202
238
  // 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.
203
239
  if (node.focus && scope.root)
204
240
  focusers.push({ wrapper, focus: () => widget.focus() });
241
+ // Its access key: Alt with its letter takes the focus to it, as the letter underlined in its label says.
242
+ if (node.hotkey) {
243
+ fieldKeys.push({ key: node.hotkey, wrapper, focus: () => widget.focus() });
244
+ const control = widget.element.matches(CONTROL) ? widget.element : widget.element.querySelector(CONTROL);
245
+ control?.setAttribute('aria-keyshortcuts', (0, core_1.hotkeyWords)(node.hotkey));
246
+ }
205
247
  // 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.
206
248
  const textBox = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
207
249
  if (labelsAt === 'hidden' && !node.placeholder)
@@ -217,10 +259,11 @@ function mountViewer(host, given) {
217
259
  // Empty and quiet, it says what to add, unless the page gives it words of its own: "Add story points", or for a
218
260
  // question "Your answer". A field of several boxes, each with its own label (an address), says it in none of them.
219
261
  const ownWords = !!node.placeholder || labelsAt === 'hidden' || def.type === 'json' || def.type === 'properties';
220
- const hint = quietHint(labelText, locale, labels);
262
+ // Unless the page says empty quiet fields show nothing at rest (quietHints false): then its label alone says what it is.
263
+ const hint = page.look?.quietHints === false ? null : quietHint(labelText, locale, labels);
221
264
  const restWords = () => (!ownWords && wrapper.hasAttribute('data-quiet') ? hint : drawnPlaceholder);
222
265
  // Where no box can hold a placeholder (a choice's blank, a state's words), the page's own words come first.
223
- const blankWords = node.placeholder || (labelsAt === 'hidden' ? labelText : hint);
266
+ const blankWords = node.placeholder || (labelsAt === 'hidden' ? labelText : (hint ?? ''));
224
267
  const quietSelect = widget.element instanceof HTMLSelectElement ? widget.element.querySelector(':scope > option[value=""]') : null;
225
268
  const richText = widget.element.matches('.fd-richtext') ? widget.element : widget.element.querySelector(':scope > .fd-richtext');
226
269
  const stateWords = widget.element.querySelector(':scope > .fd-dot-label');
@@ -284,7 +327,12 @@ function mountViewer(host, given) {
284
327
  const icon = (0, widgets_1.drawIcon)(doc, 'lock', options.icons) ?? '';
285
328
  lock = reason ? bubbleTip(`${id}-lock`, reason, fill(labels.locked, { label: labelText }), 'fd-lock', icon) : null;
286
329
  lockMark = lock?.element ?? el('span', { class: 'fd-lock-wrap' }, el('span', { class: 'fd-lock', 'aria-hidden': 'true' }, icon));
287
- if (labelsAt === 'hidden')
330
+ // At the value's start, as a work item's Reason: in a record head's band, in the room before the value.
331
+ if (page.look?.lockAt === 'value') {
332
+ widget.element.before(lockMark);
333
+ wrapper.setAttribute('data-lock-at', 'value');
334
+ }
335
+ else if (labelsAt === 'hidden')
288
336
  widget.element.after(lockMark);
289
337
  else
290
338
  label.insertBefore(lockMark, tip && labelsAt !== 'after' ? tip.element : null);
@@ -329,6 +377,11 @@ function mountViewer(host, given) {
329
377
  if (!wrapper.contains(event.relatedTarget))
330
378
  entered = form.getState().values[node.field];
331
379
  });
380
+ // Entered before the record came (a dialog takes the focus at once): what it held is what the record brought.
381
+ cleanups.push(form.on('open', ({ values }) => {
382
+ if (wrapper.contains(doc.activeElement))
383
+ entered = values[node.field];
384
+ }));
332
385
  wrapper.addEventListener('keydown', (event) => {
333
386
  if (event.key !== 'Escape' || event.defaultPrevented || !wrapper.hasAttribute('data-quiet'))
334
387
  return;
@@ -452,25 +505,38 @@ function mountViewer(host, given) {
452
505
  watch(() => (0, dom_1.setHidden)(element, form.node(id).invisible));
453
506
  }
454
507
  function buttonItem(node) {
508
+ // Its icon alone, a toggle, a menu beside it, a look that follows the record's changes: drawn by button-parts.ts.
509
+ const head = (0, button_parts_1.isRichButton)(node) ? viewer_parts_1.headPart.loaded : undefined;
510
+ if (head) {
511
+ const rich = head.richButton({ doc, el, form, labels, icons: options.icons, uid, withIcon, press, say: (message, tone) => actionHost.say(message, tone), watch, cleanups }, node);
512
+ spans(rich, node.colspan);
513
+ return hiddenOn(node, rich);
514
+ }
455
515
  const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
456
516
  if (node.hotkey)
457
517
  (0, hotkeys_1.hotkeyOn)(button, node.hotkey, node.label);
458
518
  spans(button, node.colspan);
459
- // Its confirmation, then its steps: the form asks, through the viewer.
460
- button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
519
+ // Its confirmation, then its steps: the form asks, through the viewer. Undo with nothing to put back says so.
520
+ button.addEventListener('click', () => {
521
+ if (!(0, button_parts_1.nothingToUndo)(node, form, (message, tone) => actionHost.say(message, tone), labels))
522
+ void press(button, () => form.runAction(node.id));
523
+ });
461
524
  hideWhen(button, node.id);
462
525
  // A header's button is drawn here too, never through item(): hidden at its widths the same way.
463
526
  return hiddenOn(node, button);
464
527
  }
465
528
  function textItem(node, inline = false) {
466
529
  const style = node.style ?? 'paragraph';
467
- // Its words, with the values of the fields they name.
530
+ // Its words, with the values of the fields they name, and its [words](address) as links.
468
531
  const words = (0, value_words_1.valueWords)(doc, page.fields, node.text, locale);
532
+ const shown = (0, section_head_1.wordsWithLinks)(doc, words.nodes);
469
533
  const element = inline
470
- ? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...words.nodes)
534
+ ? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...shown)
471
535
  : style === 'alert'
472
- ? 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))
473
- : el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...words.nodes);
536
+ ? 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' }, ...shown))
537
+ : style === 'help'
538
+ ? (0, section_head_1.helpBox)(headKit, node, shown)
539
+ : el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...shown);
474
540
  spans(element, node.colspan);
475
541
  hideWhen(element, node.id);
476
542
  watch((state) => words.update(state.values));
@@ -571,7 +637,18 @@ function mountViewer(host, given) {
571
637
  section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
572
638
  const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
573
639
  const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
574
- if (node.title && node.collapsible) {
640
+ // Its own column widths and gap; a heading with tools at its end, as a work item's group has.
641
+ (0, section_head_1.applyTracks)(content, node);
642
+ const own = (0, section_head_1.loneFieldTools)(node, content);
643
+ const tools = node.title && ((0, section_head_1.workHeading)(node) || own.length) ? viewer_parts_1.headPart.loaded : undefined;
644
+ if (tools) {
645
+ content.id = uid(`${node.id}-content`);
646
+ const head = tools.sectionHeading(headKit, node, section, content, description, own);
647
+ if (node.collapsible)
648
+ folds.set(section, () => head.show(true));
649
+ section.append(head.legend);
650
+ }
651
+ else if (node.title && node.collapsible) {
575
652
  content.id = uid(`${node.id}-content`);
576
653
  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));
577
654
  let open = node.collapsed !== true;
@@ -614,7 +691,12 @@ function mountViewer(host, given) {
614
691
  const parts = node.children.map((tab) => {
615
692
  const tabId = uid(`${tab.id}-tab`);
616
693
  const panelId = uid(`${tab.id}-panel`);
617
- 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));
694
+ const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, ...tabFace(tab));
695
+ if (tab.iconOnly) {
696
+ button.classList.add('fd-tab-icon-only');
697
+ button.setAttribute('aria-label', tab.label);
698
+ button.title = tab.label;
699
+ }
618
700
  const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId });
619
701
  /**
620
702
  * A tab's parts are drawn when it is first shown, and kept up to date
@@ -664,12 +746,36 @@ function mountViewer(host, given) {
664
746
  // Too long for its room, the strip scrolls; the tab chosen, however, comes into sight in it.
665
747
  const strip = (0, tab_strip_1.tabStrip)(list, el);
666
748
  cleanups.push(strip.destroy);
667
- box.append(strip.element, ...parts.map((p) => p.panel));
749
+ // Placed in a record's head: the tabs at the end of its band, the panels the sheet's body.
750
+ const inHead = node.place === 'head' && headNow && scope.root ? headNow : null;
751
+ if (inHead) {
752
+ box.classList.add('fd-tabs-in-head');
753
+ inHead.tabs.append(strip.element);
754
+ box.append(...parts.map((p) => p.panel));
755
+ }
756
+ else
757
+ box.append(strip.element, ...parts.map((p) => p.panel));
758
+ // A tab's count in its pill, and in its name: "Links, 0".
759
+ for (const p of parts) {
760
+ const count = p.tab.count;
761
+ if (count === undefined)
762
+ continue;
763
+ const pill = p.button.querySelector('.fd-tab-count');
764
+ watch((state) => {
765
+ const value = state.values[count];
766
+ const n = Array.isArray(value) ? value.length : typeof value === 'number' ? value : Number(value ?? 0) || 0;
767
+ (0, dom_1.setText)(pill, String(n));
768
+ (0, dom_1.setAttr)(p.button, 'aria-label', fill(labels.countOf, { label: p.tab.label, count: n }));
769
+ });
770
+ }
668
771
  let shownKey = '';
669
772
  let revealed = '';
670
773
  watch((state) => {
671
774
  const tabsHidden = form.node(node.id).invisible;
672
775
  (0, dom_1.setHidden)(box, tabsHidden);
776
+ // In the head, its strip is apart from its box: hidden with it.
777
+ if (inHead)
778
+ (0, dom_1.setHidden)(strip.element, tabsHidden);
673
779
  const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
674
780
  const keep = picked !== null && shown.some((p) => p.tab.id === picked);
675
781
  if (keep)
@@ -1225,6 +1331,10 @@ function mountViewer(host, given) {
1225
1331
  return box;
1226
1332
  }
1227
1333
  function sheetLayout(node) {
1334
+ // A head comes in the head part, waited for before the page is drawn; where it could not come, the sheet is drawn plainly.
1335
+ const part = node.head ? viewer_parts_1.headPart.loaded : undefined;
1336
+ if (part)
1337
+ return headedLayout(node, part);
1228
1338
  const pageBox = el('div', { class: 'fd-sheet-page', 'data-node': node.id });
1229
1339
  const actions = el('div', { class: 'fd-actions' });
1230
1340
  const save = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, labels.save);
@@ -1320,41 +1430,7 @@ function mountViewer(host, given) {
1320
1430
  });
1321
1431
  card.append(stats);
1322
1432
  }
1323
- for (const alert of node.alerts ?? []) {
1324
- // Its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled.
1325
- const words = (0, value_words_1.valueWords)(doc, page.fields, alert.message, locale);
1326
- const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
1327
- let fromField = false;
1328
- const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
1329
- if (alert.buttons?.length)
1330
- box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
1331
- updaters.push((state) => {
1332
- words.update(state.values);
1333
- const from = alert.messageField ? state.values[alert.messageField] : null;
1334
- const said = typeof from === 'string' ? from.trim() : '';
1335
- if (said)
1336
- (0, dom_1.setText)(message, said);
1337
- else if (fromField)
1338
- message.replaceChildren(...words.nodes);
1339
- fromField = !!said;
1340
- });
1341
- if (alert.dismissible) {
1342
- // Closed, it stays closed while the page is open, whatever its condition does.
1343
- let dismissed = false;
1344
- const close = el('button', { type: 'button', class: 'fd-alert-close', 'aria-label': labels.dismiss }, '×');
1345
- close.addEventListener('click', () => {
1346
- dismissed = true;
1347
- box.hidden = true;
1348
- });
1349
- box.append(close);
1350
- updaters.push(() => {
1351
- box.hidden = dismissed || form.node(alert.id).invisible;
1352
- });
1353
- }
1354
- else
1355
- hideWhen(box, alert.id);
1356
- card.append(box);
1357
- }
1433
+ card.append(...alertBoxes(node));
1358
1434
  if (node.badges?.length) {
1359
1435
  const badges = el('div', { class: 'fd-badges' });
1360
1436
  for (const badge of node.badges) {
@@ -1431,6 +1507,95 @@ function mountViewer(host, given) {
1431
1507
  pageBox.append(...(bar ? [bar] : []), header, layout);
1432
1508
  return pageBox;
1433
1509
  }
1510
+ /** The sheet's alerts: its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled. */
1511
+ function alertBoxes(node) {
1512
+ return (node.alerts ?? []).map((alert) => {
1513
+ const words = (0, value_words_1.valueWords)(doc, page.fields, alert.message, locale);
1514
+ const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
1515
+ let fromField = false;
1516
+ const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
1517
+ if (alert.buttons?.length)
1518
+ box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
1519
+ updaters.push((state) => {
1520
+ words.update(state.values);
1521
+ const from = alert.messageField ? state.values[alert.messageField] : null;
1522
+ const said = typeof from === 'string' ? from.trim() : '';
1523
+ if (said)
1524
+ (0, dom_1.setText)(message, said);
1525
+ else if (fromField)
1526
+ message.replaceChildren(...words.nodes);
1527
+ fromField = !!said;
1528
+ });
1529
+ if (alert.dismissible) {
1530
+ // Closed, it stays closed while the page is open, whatever its condition does.
1531
+ let dismissed = false;
1532
+ const close = el('button', { type: 'button', class: 'fd-alert-close', 'aria-label': labels.dismiss }, '×');
1533
+ close.addEventListener('click', () => {
1534
+ dismissed = true;
1535
+ box.hidden = true;
1536
+ });
1537
+ box.append(close);
1538
+ updaters.push(() => {
1539
+ box.hidden = dismissed || form.node(alert.id).invisible;
1540
+ });
1541
+ }
1542
+ else
1543
+ hideWhen(box, alert.id);
1544
+ return box;
1545
+ });
1546
+ }
1547
+ /**
1548
+ * A record with a head (`SheetHead`), as a work item: the head — its kind,
1549
+ * title, row, buttons and band, the tabs placed in it — then the body, which
1550
+ * holds its alerts and its parts (a tab's panels, when its tabs are in the
1551
+ * head). The record's breadcrumbs and pager, when the app gives them, over
1552
+ * it; its gear menu is the head's ⋮.
1553
+ */
1554
+ function headedLayout(node, part) {
1555
+ const pageBox = el('div', { class: 'fd-sheet-page fd-head-page', 'data-node': node.id });
1556
+ const head = part.recordHead({
1557
+ doc,
1558
+ el,
1559
+ form,
1560
+ page,
1561
+ sheet: node,
1562
+ head: node.head,
1563
+ labels,
1564
+ locale,
1565
+ icons: options.icons,
1566
+ uid,
1567
+ fill,
1568
+ withIcon,
1569
+ press,
1570
+ say: (message, tone) => actionHost.say(message, tone),
1571
+ fieldItem,
1572
+ item,
1573
+ buttonItem,
1574
+ watch: (update) => updaters.push(update),
1575
+ cleanups,
1576
+ showTab: (id) => tabs.get(id)?.() ?? false,
1577
+ unfold(part) {
1578
+ for (let folded = part.closest('.fd-section-folded'); folded; folded = folded.parentElement?.closest('.fd-section-folded') ?? null)
1579
+ folds.get(folded)?.();
1580
+ },
1581
+ root: () => root,
1582
+ });
1583
+ // The parts drawn now: tabs placed in the head go to its band.
1584
+ headNow = head;
1585
+ let parts;
1586
+ try {
1587
+ parts = grid(node.children, 1, top(false));
1588
+ }
1589
+ finally {
1590
+ headNow = null;
1591
+ }
1592
+ const body = el('div', { class: 'fd-head-body' }, ...alertBoxes(node), parts);
1593
+ // The side panel beside the body on a wide page; a dialog leaves it to the full page.
1594
+ const side = node.sidePanel ? el('aside', { class: 'fd-side' }, slotItem(node.sidePanel)) : null;
1595
+ const bar = topBar(node.toolbar ? { ...node.toolbar, menu: undefined } : undefined);
1596
+ pageBox.append(...(bar ? [bar] : []), head.element, side ? el('div', { class: 'fd-head-layout' }, body, side) : body);
1597
+ return pageBox;
1598
+ }
1434
1599
  /** The sheet header's statusbar: the same widget a form can place anywhere. */
1435
1600
  function statusbar(config) {
1436
1601
  const def = page.fields[config.field];
@@ -1453,42 +1618,76 @@ function mountViewer(host, given) {
1453
1618
  }
1454
1619
  let wizardForward = null;
1455
1620
  const layout = page.layout;
1456
- let body;
1457
- if (layout.type === 'sheet')
1458
- body = sheetLayout(layout);
1459
- else if (layout.type === 'wizard')
1460
- body = wizardLayout(layout);
1461
- else if (layout.type === 'tabs')
1462
- body = sectionsLayout({ id: `${layout.id}-page`, children: [layout] });
1463
- else if (layout.type === 'list') {
1464
- const list = (0, list_1.listView)({
1465
- page,
1466
- node: layout,
1467
- form,
1468
- dataSource: options.dataSource,
1469
- doc,
1470
- el,
1471
- labels,
1472
- locale,
1473
- fill,
1474
- press,
1475
- withIcon,
1476
- uid,
1477
- icons: options.icons,
1478
- preferences,
1479
- onOpenRecord: options.onOpenRecord,
1480
- ...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
1481
- ...(options.listViews ? { views: options.listViews } : {}),
1621
+ /** A list page's way to ask for its records again (handle.reload). */
1622
+ let reloadList = null;
1623
+ /** And its way to open a record as its row's press does (handle.openRecord). */
1624
+ let openInList = null;
1625
+ /**
1626
+ * The page itself — its layout, head and bar — drawn whole: at once when every part it draws from is in, else once
1627
+ * they have come (or could not, when what they would have drawn is drawn plainly). Nothing of it is shown before, so
1628
+ * nothing moves as a part arrives, and no focus is lost: there was nothing to have it.
1629
+ */
1630
+ function drawPage() {
1631
+ let body;
1632
+ if (layout.type === 'sheet')
1633
+ body = sheetLayout(layout);
1634
+ else if (layout.type === 'wizard')
1635
+ body = wizardLayout(layout);
1636
+ else if (layout.type === 'tabs')
1637
+ body = sectionsLayout({ id: `${layout.id}-page`, children: [layout] });
1638
+ else if (layout.type === 'list') {
1639
+ const list = (0, list_1.listView)({
1640
+ page,
1641
+ node: layout,
1642
+ form,
1643
+ dataSource: options.dataSource,
1644
+ doc,
1645
+ el,
1646
+ labels,
1647
+ locale,
1648
+ fill,
1649
+ press,
1650
+ withIcon,
1651
+ uid,
1652
+ icons: options.icons,
1653
+ preferences,
1654
+ onOpenRecord: options.onOpenRecord,
1655
+ ...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
1656
+ ...(options.listViews ? { views: options.listViews } : {}),
1657
+ });
1658
+ cleanups.push(list.destroy);
1659
+ reloadList = list.reload;
1660
+ openInList = list.open;
1661
+ body = list.element;
1662
+ }
1663
+ else
1664
+ body = sectionsLayout(layout);
1665
+ const head = layout.type === 'sheet' ? null : pageHead();
1666
+ // A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
1667
+ const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
1668
+ content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
1669
+ pageDrawn = true;
1670
+ }
1671
+ let pageDrawn = false;
1672
+ /** What needs the page drawn — a list's rows to open or ask for again — at once when it is, else once it is. */
1673
+ const afterDrawing = (then) => (pageDrawn ? then() : drawn.then(then));
1674
+ const awaited = (0, viewer_parts_1.partsFor)(page, options.widgets, options.slots).filter((part) => !part.loaded);
1675
+ let drawn = Promise.resolve();
1676
+ if (!awaited.length)
1677
+ drawPage();
1678
+ else {
1679
+ root.setAttribute('aria-busy', 'true');
1680
+ drawn = (0, widgets_1.settleParts)(awaited).then(() => {
1681
+ if (gone)
1682
+ return;
1683
+ drawPage();
1684
+ root.removeAttribute('aria-busy');
1685
+ render(form.getState());
1686
+ // A record that opened meanwhile: its focused field takes the focus now, as it would have then.
1687
+ if (opened)
1688
+ focusAsOpened(true);
1482
1689
  });
1483
- cleanups.push(list.destroy);
1484
- body = list.element;
1485
1690
  }
1486
- else
1487
- body = sectionsLayout(layout);
1488
- const head = layout.type === 'sheet' ? null : pageHead();
1489
- // A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
1490
- const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
1491
- content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
1492
1691
  root.append(content, done, announcer);
1493
1692
  // The status leaves its place beside Save when the page wants it in a corner or across the top.
1494
1693
  if (options.saveStatus === 'toast') {
@@ -1601,6 +1800,14 @@ function mountViewer(host, given) {
1601
1800
  if (!key || !hotkeysHere(event.target))
1602
1801
  return;
1603
1802
  const button = [...root.querySelectorAll(`button[data-hotkey="${key}"]`)].find((b) => b.closest('.fd-form') === root && !b.closest('[hidden]') && !b.disabled && !b.hasAttribute('aria-busy'));
1803
+ // A field's access key: the first shown with it takes the focus.
1804
+ const field = button ? undefined : fieldKeys.find((one) => one.key === key && one.wrapper.isConnected && !one.wrapper.closest('[hidden]'));
1805
+ if (field) {
1806
+ event.preventDefault();
1807
+ root.removeAttribute('data-hotkeys');
1808
+ field.focus();
1809
+ return;
1810
+ }
1604
1811
  if (!button)
1605
1812
  return;
1606
1813
  event.preventDefault();
@@ -1675,22 +1882,24 @@ function mountViewer(host, given) {
1675
1882
  mounted = true;
1676
1883
  // As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
1677
1884
  // only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
1678
- let opened = false;
1679
- if (focusers.length) {
1680
- cleanups.push(form.on('open', () => {
1681
- const first = !opened;
1682
- opened = true;
1683
- queueMicrotask(() => {
1684
- if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
1685
- return;
1686
- const active = doc.activeElement;
1687
- const free = !active || active === doc.body || active === doc.documentElement;
1688
- if (!free && !(first && root.contains(active)))
1689
- return;
1690
- focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
1691
- });
1692
- }));
1885
+ function focusAsOpened(first) {
1886
+ if (!focusers.length)
1887
+ return;
1888
+ queueMicrotask(() => {
1889
+ if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
1890
+ return;
1891
+ const active = doc.activeElement;
1892
+ const free = !active || active === doc.body || active === doc.documentElement;
1893
+ if (!free && !(first && root.contains(active)))
1894
+ return;
1895
+ focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
1896
+ });
1693
1897
  }
1898
+ cleanups.push(form.on('open', () => {
1899
+ const first = !opened;
1900
+ opened = true;
1901
+ focusAsOpened(first);
1902
+ }));
1694
1903
  if (form.getState().status === 'idle')
1695
1904
  void form.load();
1696
1905
  return {
@@ -1731,6 +1940,14 @@ function mountViewer(host, given) {
1731
1940
  render(form.getState());
1732
1941
  },
1733
1942
  isReadonly: () => locked,
1943
+ reload: () => afterDrawing(async () => {
1944
+ if (reloadList)
1945
+ return reloadList();
1946
+ await form.run([{ do: 'reload' }]);
1947
+ }),
1948
+ openRecord: (id, options) => afterDrawing(async () => {
1949
+ await openInList?.(id, options);
1950
+ }),
1734
1951
  on: (event, listener) => {
1735
1952
  const off = form.on(event, listener);
1736
1953
  cleanups.push(off);
@@ -1738,6 +1955,7 @@ function mountViewer(host, given) {
1738
1955
  },
1739
1956
  setValues: (values) => form.setValues(values),
1740
1957
  run: (steps, context) => form.run(steps, context),
1958
+ drawn,
1741
1959
  destroy() {
1742
1960
  gone = true;
1743
1961
  unsubscribe();
@@ -1749,6 +1967,8 @@ function mountViewer(host, given) {
1749
1967
  },
1750
1968
  };
1751
1969
  }
1970
+ /** What a field is worked with: where its access key is said. */
1971
+ const CONTROL = 'input:not([type="hidden"]), select, textarea, button, [contenteditable="true"], [tabindex="0"]';
1752
1972
  /** The boxes a label can be shown in, as the words of an empty box. */
1753
1973
  const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
1754
1974
  /** Nothing in it: no value, empty words, nothing picked. */