@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
@@ -1,5 +1,5 @@
1
- import { createForm, checkPage, isRightToLeft, localizePage, translatePage, wideColumns, MESSAGES, } from '@fieldia/core';
2
- import { browserPreferences, createWidget, displayValue, drawIcon, installStyles, readsAsText, readText, wearsQuiet, WIDGET_LABELS } from '@fieldia/widgets';
1
+ import { createForm, checkPage, isRightToLeft, localizePage, translatePage, wideColumns, MESSAGES, hotkeyWords, } from '@fieldia/core';
2
+ import { browserPreferences, createWidget, displayValue, drawIcon, installStyles, readsAsText, readText, settleParts, wearsQuiet, WIDGET_LABELS } from '@fieldia/widgets';
3
3
  import { findPage, pageDialogs } from './related.mjs';
4
4
  import { listView } from './list.mjs';
5
5
  import { applyLook, skinFor, wearTokens } from './look.mjs';
@@ -12,7 +12,10 @@ import { valueWords } from './value-words.mjs';
12
12
  import { hotkeyOf, hotkeyOn } from './hotkeys.mjs';
13
13
  import { recordBar } from './record-bar.mjs';
14
14
  import { attachmentPreview } from './attachment-preview.mjs';
15
+ import { applyTracks, helpBox, keepTools, loneFieldTools, wordsWithLinks, workHeading } from './section-head.mjs';
15
16
  import { installRecordStyles } from './record-styles.mjs';
17
+ import { isRichButton, nothingToUndo } from './button-parts.mjs';
18
+ import { headPart, partsFor } from './viewer-parts.mjs';
16
19
  import { ownLocale, VIEWER_LABELS } from './labels.mjs';
17
20
  import { keepTabIn } from './focus-trap.mjs';
18
21
  export { VIEWER_LABELS, DEFAULT_LABELS } from './labels.mjs';
@@ -110,6 +113,31 @@ export function mountViewer(host, given) {
110
113
  const words = el('bdi', {}, text);
111
114
  return icon ? [icon, words] : [words];
112
115
  }
116
+ /**
117
+ * A tab's face: its icon and words, or its icon alone (its words then its
118
+ * name and tooltip), and the pill of its count after them.
119
+ */
120
+ function tabFace(tab) {
121
+ const pill = tab.count !== undefined ? [el('span', { class: 'fd-tab-count', 'aria-hidden': 'true' }, '0')] : [];
122
+ if (!tab.iconOnly)
123
+ return [...withIcon(tab.icon, tab.label), ...pill];
124
+ const icon = drawIcon(doc, tab.icon, options.icons);
125
+ return [...(icon ? [icon] : []), ...pill];
126
+ }
127
+ /**
128
+ * A label's words with its access key's letter underlined, as a work item's
129
+ * "Stat_e_": the first of that letter in them, whatever its case. Its words
130
+ * stay one piece for whatever reads them; without a key, the words alone.
131
+ */
132
+ function accessKeyed(text, key) {
133
+ const words = keepMarkWithWord(text);
134
+ const at = key ? words.toLocaleLowerCase().indexOf(key) : -1;
135
+ if (at === -1)
136
+ return [words];
137
+ 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))];
138
+ }
139
+ /** What a group's heading and a help box draw with. */
140
+ const headKit = { doc, labels: widgetLabels, icons: options.icons, withIcon, el };
113
141
  const theme = options.theme ?? page.look?.theme;
114
142
  const root = el('form', { class: 'fd-form', novalidate: '', 'data-fd-skin': skinFor(options.skin ?? page.look?.skin, theme), dir, lang: tag, 'data-max-width': page.maxWidth });
115
143
  applyLook(root, theme ? { ...page.look, theme } : page.look);
@@ -146,15 +174,21 @@ export function mountViewer(host, given) {
146
174
  // ---- the parts -------------------------------------------------------
147
175
  /** The fields that take the focus as the record opens, in the order they are drawn. */
148
176
  const focusers = [];
177
+ /** The fields with an access key, in the order they are drawn: Alt with the key focuses the first shown. */
178
+ const fieldKeys = [];
149
179
  /** Each field's face, put on again when the form changes skin: quiet, or in its box. */
150
180
  const faces = [];
151
181
  /** Folded sections, and how to open each: a problem inside one has to be seen. */
152
182
  const folds = new Map();
153
183
  /** Every part drawn again from the state of the page shown. */
154
184
  const renderNow = () => render(form.getState());
185
+ /** The record's head being drawn: tabs placed in it go to its band. */
186
+ let headNow = null;
155
187
  /** Set once the viewer is on the page; set again when it is taken away. */
156
188
  let mounted = false;
157
189
  let gone = false;
190
+ /** Whether the form has opened a record yet (its `open` event). */
191
+ let opened = false;
158
192
  /** The columns a part spans in the grid it sits in; the stylesheet keeps it within the columns there at each width. */
159
193
  function spans(element, colspan) {
160
194
  if (colspan)
@@ -183,7 +217,7 @@ export function mountViewer(host, given) {
183
217
  if (node.colspan)
184
218
  wrapper.style.setProperty('--fd-span', String(node.colspan));
185
219
  const labelText = node.label ?? def.label;
186
- const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, keepMarkWithWord(labelText));
220
+ const label = el('label', { class: 'fd-label', id: `${id}-label`, for: id }, ...accessKeyed(labelText, node.hotkey));
187
221
  // The ✓ of a field filled in right; never on a yes/no box, a table or a file, where it would say nothing.
188
222
  const mark = options.showValid && !NO_VALID_MARK.has(def.type) ? drawIcon(doc, 'check') : null;
189
223
  if (mark) {
@@ -191,9 +225,17 @@ export function mountViewer(host, given) {
191
225
  label.append(mark);
192
226
  }
193
227
  const widget = createWidget({ form, name: node.field, field: def, node, id, document: doc, labels: widgetLabels, preferences, locale, dialogs }, options.widgets);
228
+ // Its own tools (a Markdown text's Edit and Done), for a group that holds it alone to put in its heading.
229
+ keepTools(wrapper, widget);
194
230
  // 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.
195
231
  if (node.focus && scope.root)
196
232
  focusers.push({ wrapper, focus: () => widget.focus() });
233
+ // Its access key: Alt with its letter takes the focus to it, as the letter underlined in its label says.
234
+ if (node.hotkey) {
235
+ fieldKeys.push({ key: node.hotkey, wrapper, focus: () => widget.focus() });
236
+ const control = widget.element.matches(CONTROL) ? widget.element : widget.element.querySelector(CONTROL);
237
+ control?.setAttribute('aria-keyshortcuts', hotkeyWords(node.hotkey));
238
+ }
197
239
  // 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.
198
240
  const textBox = widget.element.matches(TEXT_BOX) ? widget.element : widget.element.querySelector(TEXT_BOX);
199
241
  if (labelsAt === 'hidden' && !node.placeholder)
@@ -209,10 +251,11 @@ export function mountViewer(host, given) {
209
251
  // Empty and quiet, it says what to add, unless the page gives it words of its own: "Add story points", or for a
210
252
  // question "Your answer". A field of several boxes, each with its own label (an address), says it in none of them.
211
253
  const ownWords = !!node.placeholder || labelsAt === 'hidden' || def.type === 'json' || def.type === 'properties';
212
- const hint = quietHint(labelText, locale, labels);
254
+ // Unless the page says empty quiet fields show nothing at rest (quietHints false): then its label alone says what it is.
255
+ const hint = page.look?.quietHints === false ? null : quietHint(labelText, locale, labels);
213
256
  const restWords = () => (!ownWords && wrapper.hasAttribute('data-quiet') ? hint : drawnPlaceholder);
214
257
  // Where no box can hold a placeholder (a choice's blank, a state's words), the page's own words come first.
215
- const blankWords = node.placeholder || (labelsAt === 'hidden' ? labelText : hint);
258
+ const blankWords = node.placeholder || (labelsAt === 'hidden' ? labelText : (hint ?? ''));
216
259
  const quietSelect = widget.element instanceof HTMLSelectElement ? widget.element.querySelector(':scope > option[value=""]') : null;
217
260
  const richText = widget.element.matches('.fd-richtext') ? widget.element : widget.element.querySelector(':scope > .fd-richtext');
218
261
  const stateWords = widget.element.querySelector(':scope > .fd-dot-label');
@@ -276,7 +319,12 @@ export function mountViewer(host, given) {
276
319
  const icon = drawIcon(doc, 'lock', options.icons) ?? '';
277
320
  lock = reason ? bubbleTip(`${id}-lock`, reason, fill(labels.locked, { label: labelText }), 'fd-lock', icon) : null;
278
321
  lockMark = lock?.element ?? el('span', { class: 'fd-lock-wrap' }, el('span', { class: 'fd-lock', 'aria-hidden': 'true' }, icon));
279
- if (labelsAt === 'hidden')
322
+ // At the value's start, as a work item's Reason: in a record head's band, in the room before the value.
323
+ if (page.look?.lockAt === 'value') {
324
+ widget.element.before(lockMark);
325
+ wrapper.setAttribute('data-lock-at', 'value');
326
+ }
327
+ else if (labelsAt === 'hidden')
280
328
  widget.element.after(lockMark);
281
329
  else
282
330
  label.insertBefore(lockMark, tip && labelsAt !== 'after' ? tip.element : null);
@@ -321,6 +369,11 @@ export function mountViewer(host, given) {
321
369
  if (!wrapper.contains(event.relatedTarget))
322
370
  entered = form.getState().values[node.field];
323
371
  });
372
+ // Entered before the record came (a dialog takes the focus at once): what it held is what the record brought.
373
+ cleanups.push(form.on('open', ({ values }) => {
374
+ if (wrapper.contains(doc.activeElement))
375
+ entered = values[node.field];
376
+ }));
324
377
  wrapper.addEventListener('keydown', (event) => {
325
378
  if (event.key !== 'Escape' || event.defaultPrevented || !wrapper.hasAttribute('data-quiet'))
326
379
  return;
@@ -444,25 +497,38 @@ export function mountViewer(host, given) {
444
497
  watch(() => setHidden(element, form.node(id).invisible));
445
498
  }
446
499
  function buttonItem(node) {
500
+ // Its icon alone, a toggle, a menu beside it, a look that follows the record's changes: drawn by button-parts.ts.
501
+ const head = isRichButton(node) ? headPart.loaded : undefined;
502
+ if (head) {
503
+ const rich = head.richButton({ doc, el, form, labels, icons: options.icons, uid, withIcon, press, say: (message, tone) => actionHost.say(message, tone), watch, cleanups }, node);
504
+ spans(rich, node.colspan);
505
+ return hiddenOn(node, rich);
506
+ }
447
507
  const button = el('button', { type: 'button', class: `fd-button fd-button-${node.style ?? 'secondary'}`, 'data-node': node.id }, ...withIcon(node.icon, node.label));
448
508
  if (node.hotkey)
449
509
  hotkeyOn(button, node.hotkey, node.label);
450
510
  spans(button, node.colspan);
451
- // Its confirmation, then its steps: the form asks, through the viewer.
452
- button.addEventListener('click', () => void press(button, () => form.runAction(node.id)));
511
+ // Its confirmation, then its steps: the form asks, through the viewer. Undo with nothing to put back says so.
512
+ button.addEventListener('click', () => {
513
+ if (!nothingToUndo(node, form, (message, tone) => actionHost.say(message, tone), labels))
514
+ void press(button, () => form.runAction(node.id));
515
+ });
453
516
  hideWhen(button, node.id);
454
517
  // A header's button is drawn here too, never through item(): hidden at its widths the same way.
455
518
  return hiddenOn(node, button);
456
519
  }
457
520
  function textItem(node, inline = false) {
458
521
  const style = node.style ?? 'paragraph';
459
- // Its words, with the values of the fields they name.
522
+ // Its words, with the values of the fields they name, and its [words](address) as links.
460
523
  const words = valueWords(doc, page.fields, node.text, locale);
524
+ const shown = wordsWithLinks(doc, words.nodes);
461
525
  const element = inline
462
- ? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...words.nodes)
526
+ ? el('span', { class: `fd-oneline-words fd-text-${style}`, 'data-node': node.id }, ...shown)
463
527
  : style === 'alert'
464
- ? 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))
465
- : el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...words.nodes);
528
+ ? 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))
529
+ : style === 'help'
530
+ ? helpBox(headKit, node, shown)
531
+ : el(style === 'heading' ? 'h3' : 'p', { class: `fd-text-${style}`, 'data-node': node.id }, ...shown);
466
532
  spans(element, node.colspan);
467
533
  hideWhen(element, node.id);
468
534
  watch((state) => words.update(state.values));
@@ -563,7 +629,18 @@ export function mountViewer(host, given) {
563
629
  section.style.setProperty('--fd-label-width', `${node.labelWidth}px`);
564
630
  const description = node.description ? el('p', { class: 'fd-section-description' }, node.description) : null;
565
631
  const content = grid(node.children, plan.at === 'tracks' ? null : node.columns, plan.inner);
566
- if (node.title && node.collapsible) {
632
+ // Its own column widths and gap; a heading with tools at its end, as a work item's group has.
633
+ applyTracks(content, node);
634
+ const own = loneFieldTools(node, content);
635
+ const tools = node.title && (workHeading(node) || own.length) ? headPart.loaded : undefined;
636
+ if (tools) {
637
+ content.id = uid(`${node.id}-content`);
638
+ const head = tools.sectionHeading(headKit, node, section, content, description, own);
639
+ if (node.collapsible)
640
+ folds.set(section, () => head.show(true));
641
+ section.append(head.legend);
642
+ }
643
+ else if (node.title && node.collapsible) {
567
644
  content.id = uid(`${node.id}-content`);
568
645
  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));
569
646
  let open = node.collapsed !== true;
@@ -606,7 +683,12 @@ export function mountViewer(host, given) {
606
683
  const parts = node.children.map((tab) => {
607
684
  const tabId = uid(`${tab.id}-tab`);
608
685
  const panelId = uid(`${tab.id}-panel`);
609
- 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));
686
+ const button = el('button', { type: 'button', class: 'fd-tab', role: 'tab', id: tabId, 'aria-controls': panelId, 'data-node': tab.id }, ...tabFace(tab));
687
+ if (tab.iconOnly) {
688
+ button.classList.add('fd-tab-icon-only');
689
+ button.setAttribute('aria-label', tab.label);
690
+ button.title = tab.label;
691
+ }
610
692
  const panel = el('div', { class: 'fd-tabpanel', role: 'tabpanel', id: panelId, 'aria-labelledby': tabId });
611
693
  /**
612
694
  * A tab's parts are drawn when it is first shown, and kept up to date
@@ -656,12 +738,36 @@ export function mountViewer(host, given) {
656
738
  // Too long for its room, the strip scrolls; the tab chosen, however, comes into sight in it.
657
739
  const strip = tabStrip(list, el);
658
740
  cleanups.push(strip.destroy);
659
- box.append(strip.element, ...parts.map((p) => p.panel));
741
+ // Placed in a record's head: the tabs at the end of its band, the panels the sheet's body.
742
+ const inHead = node.place === 'head' && headNow && scope.root ? headNow : null;
743
+ if (inHead) {
744
+ box.classList.add('fd-tabs-in-head');
745
+ inHead.tabs.append(strip.element);
746
+ box.append(...parts.map((p) => p.panel));
747
+ }
748
+ else
749
+ box.append(strip.element, ...parts.map((p) => p.panel));
750
+ // A tab's count in its pill, and in its name: "Links, 0".
751
+ for (const p of parts) {
752
+ const count = p.tab.count;
753
+ if (count === undefined)
754
+ continue;
755
+ const pill = p.button.querySelector('.fd-tab-count');
756
+ watch((state) => {
757
+ const value = state.values[count];
758
+ const n = Array.isArray(value) ? value.length : typeof value === 'number' ? value : Number(value ?? 0) || 0;
759
+ setText(pill, String(n));
760
+ setAttr(p.button, 'aria-label', fill(labels.countOf, { label: p.tab.label, count: n }));
761
+ });
762
+ }
660
763
  let shownKey = '';
661
764
  let revealed = '';
662
765
  watch((state) => {
663
766
  const tabsHidden = form.node(node.id).invisible;
664
767
  setHidden(box, tabsHidden);
768
+ // In the head, its strip is apart from its box: hidden with it.
769
+ if (inHead)
770
+ setHidden(strip.element, tabsHidden);
665
771
  const shown = parts.filter((p) => !form.node(p.tab.id).invisible);
666
772
  const keep = picked !== null && shown.some((p) => p.tab.id === picked);
667
773
  if (keep)
@@ -1217,6 +1323,10 @@ export function mountViewer(host, given) {
1217
1323
  return box;
1218
1324
  }
1219
1325
  function sheetLayout(node) {
1326
+ // A head comes in the head part, waited for before the page is drawn; where it could not come, the sheet is drawn plainly.
1327
+ const part = node.head ? headPart.loaded : undefined;
1328
+ if (part)
1329
+ return headedLayout(node, part);
1220
1330
  const pageBox = el('div', { class: 'fd-sheet-page', 'data-node': node.id });
1221
1331
  const actions = el('div', { class: 'fd-actions' });
1222
1332
  const save = el('button', { type: 'submit', class: 'fd-button fd-button-primary' }, labels.save);
@@ -1312,41 +1422,7 @@ export function mountViewer(host, given) {
1312
1422
  });
1313
1423
  card.append(stats);
1314
1424
  }
1315
- for (const alert of node.alerts ?? []) {
1316
- // Its words with the values they name, or a field's words while it holds any; its buttons after them, as links unless styled.
1317
- const words = valueWords(doc, page.fields, alert.message, locale);
1318
- const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
1319
- let fromField = false;
1320
- const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
1321
- if (alert.buttons?.length)
1322
- box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
1323
- updaters.push((state) => {
1324
- words.update(state.values);
1325
- const from = alert.messageField ? state.values[alert.messageField] : null;
1326
- const said = typeof from === 'string' ? from.trim() : '';
1327
- if (said)
1328
- setText(message, said);
1329
- else if (fromField)
1330
- message.replaceChildren(...words.nodes);
1331
- fromField = !!said;
1332
- });
1333
- if (alert.dismissible) {
1334
- // Closed, it stays closed while the page is open, whatever its condition does.
1335
- let dismissed = false;
1336
- const close = el('button', { type: 'button', class: 'fd-alert-close', 'aria-label': labels.dismiss }, '×');
1337
- close.addEventListener('click', () => {
1338
- dismissed = true;
1339
- box.hidden = true;
1340
- });
1341
- box.append(close);
1342
- updaters.push(() => {
1343
- box.hidden = dismissed || form.node(alert.id).invisible;
1344
- });
1345
- }
1346
- else
1347
- hideWhen(box, alert.id);
1348
- card.append(box);
1349
- }
1425
+ card.append(...alertBoxes(node));
1350
1426
  if (node.badges?.length) {
1351
1427
  const badges = el('div', { class: 'fd-badges' });
1352
1428
  for (const badge of node.badges) {
@@ -1423,6 +1499,95 @@ export function mountViewer(host, given) {
1423
1499
  pageBox.append(...(bar ? [bar] : []), header, layout);
1424
1500
  return pageBox;
1425
1501
  }
1502
+ /** 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. */
1503
+ function alertBoxes(node) {
1504
+ return (node.alerts ?? []).map((alert) => {
1505
+ const words = valueWords(doc, page.fields, alert.message, locale);
1506
+ const message = el('span', { class: 'fd-alert-message' }, ...words.nodes);
1507
+ let fromField = false;
1508
+ const box = el('div', { class: `fd-alert fd-tone-${alert.tone ?? 'info'}`, role: 'status', 'data-node': alert.id }, message);
1509
+ if (alert.buttons?.length)
1510
+ box.append(el('span', { class: 'fd-alert-actions' }, ...alert.buttons.map((button) => buttonItem({ ...button, style: button.style ?? 'link' }))));
1511
+ updaters.push((state) => {
1512
+ words.update(state.values);
1513
+ const from = alert.messageField ? state.values[alert.messageField] : null;
1514
+ const said = typeof from === 'string' ? from.trim() : '';
1515
+ if (said)
1516
+ setText(message, said);
1517
+ else if (fromField)
1518
+ message.replaceChildren(...words.nodes);
1519
+ fromField = !!said;
1520
+ });
1521
+ if (alert.dismissible) {
1522
+ // Closed, it stays closed while the page is open, whatever its condition does.
1523
+ let dismissed = false;
1524
+ const close = el('button', { type: 'button', class: 'fd-alert-close', 'aria-label': labels.dismiss }, '×');
1525
+ close.addEventListener('click', () => {
1526
+ dismissed = true;
1527
+ box.hidden = true;
1528
+ });
1529
+ box.append(close);
1530
+ updaters.push(() => {
1531
+ box.hidden = dismissed || form.node(alert.id).invisible;
1532
+ });
1533
+ }
1534
+ else
1535
+ hideWhen(box, alert.id);
1536
+ return box;
1537
+ });
1538
+ }
1539
+ /**
1540
+ * A record with a head (`SheetHead`), as a work item: the head — its kind,
1541
+ * title, row, buttons and band, the tabs placed in it — then the body, which
1542
+ * holds its alerts and its parts (a tab's panels, when its tabs are in the
1543
+ * head). The record's breadcrumbs and pager, when the app gives them, over
1544
+ * it; its gear menu is the head's ⋮.
1545
+ */
1546
+ function headedLayout(node, part) {
1547
+ const pageBox = el('div', { class: 'fd-sheet-page fd-head-page', 'data-node': node.id });
1548
+ const head = part.recordHead({
1549
+ doc,
1550
+ el,
1551
+ form,
1552
+ page,
1553
+ sheet: node,
1554
+ head: node.head,
1555
+ labels,
1556
+ locale,
1557
+ icons: options.icons,
1558
+ uid,
1559
+ fill,
1560
+ withIcon,
1561
+ press,
1562
+ say: (message, tone) => actionHost.say(message, tone),
1563
+ fieldItem,
1564
+ item,
1565
+ buttonItem,
1566
+ watch: (update) => updaters.push(update),
1567
+ cleanups,
1568
+ showTab: (id) => tabs.get(id)?.() ?? false,
1569
+ unfold(part) {
1570
+ for (let folded = part.closest('.fd-section-folded'); folded; folded = folded.parentElement?.closest('.fd-section-folded') ?? null)
1571
+ folds.get(folded)?.();
1572
+ },
1573
+ root: () => root,
1574
+ });
1575
+ // The parts drawn now: tabs placed in the head go to its band.
1576
+ headNow = head;
1577
+ let parts;
1578
+ try {
1579
+ parts = grid(node.children, 1, top(false));
1580
+ }
1581
+ finally {
1582
+ headNow = null;
1583
+ }
1584
+ const body = el('div', { class: 'fd-head-body' }, ...alertBoxes(node), parts);
1585
+ // The side panel beside the body on a wide page; a dialog leaves it to the full page.
1586
+ const side = node.sidePanel ? el('aside', { class: 'fd-side' }, slotItem(node.sidePanel)) : null;
1587
+ const bar = topBar(node.toolbar ? { ...node.toolbar, menu: undefined } : undefined);
1588
+ pageBox.append(...(bar ? [bar] : []), head.element, side ? el('div', { class: 'fd-head-layout' }, body, side) : body);
1589
+ return pageBox;
1590
+ }
1426
1591
  /** The sheet header's statusbar: the same widget a form can place anywhere. */
1427
1592
  function statusbar(config) {
1428
1593
  const def = page.fields[config.field];
@@ -1445,42 +1610,76 @@ export function mountViewer(host, given) {
1445
1610
  }
1446
1611
  let wizardForward = null;
1447
1612
  const layout = page.layout;
1448
- let body;
1449
- if (layout.type === 'sheet')
1450
- body = sheetLayout(layout);
1451
- else if (layout.type === 'wizard')
1452
- body = wizardLayout(layout);
1453
- else if (layout.type === 'tabs')
1454
- body = sectionsLayout({ id: `${layout.id}-page`, children: [layout] });
1455
- else if (layout.type === 'list') {
1456
- const list = listView({
1457
- page,
1458
- node: layout,
1459
- form,
1460
- dataSource: options.dataSource,
1461
- doc,
1462
- el,
1463
- labels,
1464
- locale,
1465
- fill,
1466
- press,
1467
- withIcon,
1468
- uid,
1469
- icons: options.icons,
1470
- preferences,
1471
- onOpenRecord: options.onOpenRecord,
1472
- ...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
1473
- ...(options.listViews ? { views: options.listViews } : {}),
1613
+ /** A list page's way to ask for its records again (handle.reload). */
1614
+ let reloadList = null;
1615
+ /** And its way to open a record as its row's press does (handle.openRecord). */
1616
+ let openInList = null;
1617
+ /**
1618
+ * The page itself — its layout, head and bar — drawn whole: at once when every part it draws from is in, else once
1619
+ * they have come (or could not, when what they would have drawn is drawn plainly). Nothing of it is shown before, so
1620
+ * nothing moves as a part arrives, and no focus is lost: there was nothing to have it.
1621
+ */
1622
+ function drawPage() {
1623
+ let body;
1624
+ if (layout.type === 'sheet')
1625
+ body = sheetLayout(layout);
1626
+ else if (layout.type === 'wizard')
1627
+ body = wizardLayout(layout);
1628
+ else if (layout.type === 'tabs')
1629
+ body = sectionsLayout({ id: `${layout.id}-page`, children: [layout] });
1630
+ else if (layout.type === 'list') {
1631
+ const list = listView({
1632
+ page,
1633
+ node: layout,
1634
+ form,
1635
+ dataSource: options.dataSource,
1636
+ doc,
1637
+ el,
1638
+ labels,
1639
+ locale,
1640
+ fill,
1641
+ press,
1642
+ withIcon,
1643
+ uid,
1644
+ icons: options.icons,
1645
+ preferences,
1646
+ onOpenRecord: options.onOpenRecord,
1647
+ ...(options.listFilter ? { fixedFilter: options.listFilter } : {}),
1648
+ ...(options.listViews ? { views: options.listViews } : {}),
1649
+ });
1650
+ cleanups.push(list.destroy);
1651
+ reloadList = list.reload;
1652
+ openInList = list.open;
1653
+ body = list.element;
1654
+ }
1655
+ else
1656
+ body = sectionsLayout(layout);
1657
+ const head = layout.type === 'sheet' ? null : pageHead();
1658
+ // A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
1659
+ const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
1660
+ content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
1661
+ pageDrawn = true;
1662
+ }
1663
+ let pageDrawn = false;
1664
+ /** What needs the page drawn — a list's rows to open or ask for again — at once when it is, else once it is. */
1665
+ const afterDrawing = (then) => (pageDrawn ? then() : drawn.then(then));
1666
+ const awaited = partsFor(page, options.widgets, options.slots).filter((part) => !part.loaded);
1667
+ let drawn = Promise.resolve();
1668
+ if (!awaited.length)
1669
+ drawPage();
1670
+ else {
1671
+ root.setAttribute('aria-busy', 'true');
1672
+ drawn = settleParts(awaited).then(() => {
1673
+ if (gone)
1674
+ return;
1675
+ drawPage();
1676
+ root.removeAttribute('aria-busy');
1677
+ render(form.getState());
1678
+ // A record that opened meanwhile: its focused field takes the focus now, as it would have then.
1679
+ if (opened)
1680
+ focusAsOpened(true);
1474
1681
  });
1475
- cleanups.push(list.destroy);
1476
- body = list.element;
1477
1682
  }
1478
- else
1479
- body = sectionsLayout(layout);
1480
- const head = layout.type === 'sheet' ? null : pageHead();
1481
- // A record of sections or tabs has the bar the app fills — its breadcrumbs and pager — over its title.
1482
- const bar = layout.type === 'sections' || layout.type === 'tabs' ? topBar(undefined) : null;
1483
- content.append(...(bar ? [bar] : []), ...(head ? [head] : []), banner, draft, body);
1484
1683
  root.append(content, done, announcer);
1485
1684
  // The status leaves its place beside Save when the page wants it in a corner or across the top.
1486
1685
  if (options.saveStatus === 'toast') {
@@ -1593,6 +1792,14 @@ export function mountViewer(host, given) {
1593
1792
  if (!key || !hotkeysHere(event.target))
1594
1793
  return;
1595
1794
  const button = [...root.querySelectorAll(`button[data-hotkey="${key}"]`)].find((b) => b.closest('.fd-form') === root && !b.closest('[hidden]') && !b.disabled && !b.hasAttribute('aria-busy'));
1795
+ // A field's access key: the first shown with it takes the focus.
1796
+ const field = button ? undefined : fieldKeys.find((one) => one.key === key && one.wrapper.isConnected && !one.wrapper.closest('[hidden]'));
1797
+ if (field) {
1798
+ event.preventDefault();
1799
+ root.removeAttribute('data-hotkeys');
1800
+ field.focus();
1801
+ return;
1802
+ }
1596
1803
  if (!button)
1597
1804
  return;
1598
1805
  event.preventDefault();
@@ -1667,22 +1874,24 @@ export function mountViewer(host, given) {
1667
1874
  mounted = true;
1668
1875
  // As the record opens, its focused field takes the focus — the first shown — unless the person is busy elsewhere:
1669
1876
  // only while nothing has the focus, or, the first time, while it is in this form (a dialog's first box).
1670
- let opened = false;
1671
- if (focusers.length) {
1672
- cleanups.push(form.on('open', () => {
1673
- const first = !opened;
1674
- opened = true;
1675
- queueMicrotask(() => {
1676
- if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
1677
- return;
1678
- const active = doc.activeElement;
1679
- const free = !active || active === doc.body || active === doc.documentElement;
1680
- if (!free && !(first && root.contains(active)))
1681
- return;
1682
- focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
1683
- });
1684
- }));
1877
+ function focusAsOpened(first) {
1878
+ if (!focusers.length)
1879
+ return;
1880
+ queueMicrotask(() => {
1881
+ if (gone || !root.isConnected || root.closest('[hidden]') || (typeof root.checkVisibility === 'function' && !root.checkVisibility()))
1882
+ return;
1883
+ const active = doc.activeElement;
1884
+ const free = !active || active === doc.body || active === doc.documentElement;
1885
+ if (!free && !(first && root.contains(active)))
1886
+ return;
1887
+ focusers.find((one) => !one.wrapper.closest('[hidden]'))?.focus();
1888
+ });
1685
1889
  }
1890
+ cleanups.push(form.on('open', () => {
1891
+ const first = !opened;
1892
+ opened = true;
1893
+ focusAsOpened(first);
1894
+ }));
1686
1895
  if (form.getState().status === 'idle')
1687
1896
  void form.load();
1688
1897
  return {
@@ -1723,6 +1932,14 @@ export function mountViewer(host, given) {
1723
1932
  render(form.getState());
1724
1933
  },
1725
1934
  isReadonly: () => locked,
1935
+ reload: () => afterDrawing(async () => {
1936
+ if (reloadList)
1937
+ return reloadList();
1938
+ await form.run([{ do: 'reload' }]);
1939
+ }),
1940
+ openRecord: (id, options) => afterDrawing(async () => {
1941
+ await openInList?.(id, options);
1942
+ }),
1726
1943
  on: (event, listener) => {
1727
1944
  const off = form.on(event, listener);
1728
1945
  cleanups.push(off);
@@ -1730,6 +1947,7 @@ export function mountViewer(host, given) {
1730
1947
  },
1731
1948
  setValues: (values) => form.setValues(values),
1732
1949
  run: (steps, context) => form.run(steps, context),
1950
+ drawn,
1733
1951
  destroy() {
1734
1952
  gone = true;
1735
1953
  unsubscribe();
@@ -1741,6 +1959,8 @@ export function mountViewer(host, given) {
1741
1959
  },
1742
1960
  };
1743
1961
  }
1962
+ /** What a field is worked with: where its access key is said. */
1963
+ const CONTROL = 'input:not([type="hidden"]), select, textarea, button, [contenteditable="true"], [tabindex="0"]';
1744
1964
  /** The boxes a label can be shown in, as the words of an empty box. */
1745
1965
  const TEXT_BOX = 'input:not([type="checkbox"]):not([type="radio"]):not([type="file"]):not([type="hidden"]), textarea';
1746
1966
  /** Nothing in it: no value, empty words, nothing picked. */