@zeyos/zx 4.3.0 → 4.4.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 (136) hide show
  1. package/CHANGELOG.md +266 -1
  2. package/README.md +7 -3
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +195 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +28 -0
  6. package/dist/types/components/button/button.d.ts +8 -0
  7. package/dist/types/components/calendar/calendar-model.d.ts +22 -0
  8. package/dist/types/components/calendar/calendar.d.ts +19 -0
  9. package/dist/types/components/card/card.d.ts +7 -0
  10. package/dist/types/components/card-view/card-view.d.ts +12 -4
  11. package/dist/types/components/card-view/record-card.d.ts +53 -5
  12. package/dist/types/components/code-editor/code-editor.d.ts +160 -0
  13. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  14. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  15. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  16. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  17. package/dist/types/components/dialog/dialog.d.ts +29 -0
  18. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  19. package/dist/types/components/file-item/file-item.d.ts +234 -0
  20. package/dist/types/components/file-list/file-list.d.ts +171 -0
  21. package/dist/types/components/filter/filter.d.ts +91 -36
  22. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  23. package/dist/types/components/kanban-view/kanban-card.d.ts +138 -0
  24. package/dist/types/components/kanban-view/kanban-policy.d.ts +268 -0
  25. package/dist/types/components/kanban-view/kanban-view.d.ts +837 -26
  26. package/dist/types/components/launcher/launcher.d.ts +19 -12
  27. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  28. package/dist/types/components/menu-button/menu-button.d.ts +4 -37
  29. package/dist/types/components/message/message.d.ts +7 -0
  30. package/dist/types/components/modal/modal.d.ts +48 -0
  31. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  32. package/dist/types/components/panel/panel.d.ts +7 -0
  33. package/dist/types/components/search/search.d.ts +7 -0
  34. package/dist/types/components/select/select.d.ts +4 -0
  35. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  36. package/dist/types/components/stat-tile/stat-tile.d.ts +277 -0
  37. package/dist/types/components/tabbox/tabbox.d.ts +7 -0
  38. package/dist/types/components/table/table.d.ts +144 -0
  39. package/dist/types/components/table-view/table-view.d.ts +11 -5
  40. package/dist/types/components/timebox/timebox.d.ts +7 -0
  41. package/dist/types/components/value-list/value-list.d.ts +8 -0
  42. package/dist/types/components/view/record-view.d.ts +96 -5
  43. package/dist/types/core/component.d.ts +6 -1
  44. package/dist/types/core/icons.d.ts +17 -0
  45. package/dist/types/index.d.ts +13 -1
  46. package/dist/types/internal/app-rail.d.ts +10 -0
  47. package/dist/types/internal/menu-items.d.ts +134 -0
  48. package/dist/zx-zeyos.esm.js +651 -88
  49. package/dist/zx-zeyos.esm.min.js +8 -1
  50. package/dist/zx.css +6266 -3900
  51. package/dist/zx.esm.js +8487 -1805
  52. package/dist/zx.esm.min.js +10 -3
  53. package/dist/zx.global.js +8487 -1805
  54. package/dist/zx.global.min.js +10 -3
  55. package/dist/zx.min.css +1 -1
  56. package/docs/api.json +1811 -247
  57. package/docs/llms.md +345 -40
  58. package/docs/llms.txt +11 -7
  59. package/package.json +1 -2
  60. package/src/components/activity-item/activity-item.css +137 -0
  61. package/src/components/activity-item/activity-item.js +599 -0
  62. package/src/components/activity-list/activity-list.css +64 -0
  63. package/src/components/activity-list/activity-list.js +375 -0
  64. package/src/components/app-rail/app-rail.css +227 -19
  65. package/src/components/app-sidebar/app-sidebar.css +414 -24
  66. package/src/components/app-sidebar/app-sidebar.js +65 -5
  67. package/src/components/button/button.css +8 -0
  68. package/src/components/button/button.js +6 -1
  69. package/src/components/calendar/calendar-model.js +45 -0
  70. package/src/components/calendar/calendar.css +30 -10
  71. package/src/components/calendar/calendar.js +41 -13
  72. package/src/components/card/card.js +12 -1
  73. package/src/components/card-view/card-view.css +212 -9
  74. package/src/components/card-view/card-view.js +32 -16
  75. package/src/components/card-view/record-card.js +98 -21
  76. package/src/components/code-editor/code-editor.css +67 -0
  77. package/src/components/code-editor/code-editor.js +288 -0
  78. package/src/components/code-editor/field-adapter.js +34 -0
  79. package/src/components/context-menu/context-menu.css +80 -3
  80. package/src/components/context-menu/context-menu.js +22 -30
  81. package/src/components/date-picker/date-picker.js +22 -8
  82. package/src/components/date-picker/month-picker.js +3 -3
  83. package/src/components/date-picker/time-picker.js +20 -8
  84. package/src/components/dialog/dialog.js +88 -66
  85. package/src/components/dropdown/dropdown.js +2 -2
  86. package/src/components/entity-ref/entity-ref.css +182 -0
  87. package/src/components/entity-ref/entity-ref.js +496 -0
  88. package/src/components/file-item/file-item.css +88 -0
  89. package/src/components/file-item/file-item.js +371 -0
  90. package/src/components/file-list/file-list.css +51 -0
  91. package/src/components/file-list/file-list.js +320 -0
  92. package/src/components/filter/filter.css +27 -1
  93. package/src/components/filter/filter.js +199 -45
  94. package/src/components/filter-panel/filter-panel.css +104 -0
  95. package/src/components/filter-panel/filter-panel.js +964 -0
  96. package/src/components/kanban-view/kanban-card.js +239 -0
  97. package/src/components/kanban-view/kanban-policy.js +412 -0
  98. package/src/components/kanban-view/kanban-view.css +674 -36
  99. package/src/components/kanban-view/kanban-view.js +1762 -240
  100. package/src/components/launcher/launcher.js +59 -27
  101. package/src/components/list-toolbar/list-toolbar.css +189 -0
  102. package/src/components/list-toolbar/list-toolbar.js +851 -0
  103. package/src/components/menu-button/menu-button.css +80 -3
  104. package/src/components/menu-button/menu-button.js +21 -37
  105. package/src/components/message/message.css +14 -12
  106. package/src/components/message/message.js +13 -2
  107. package/src/components/modal/modal.css +15 -0
  108. package/src/components/modal/modal.js +141 -0
  109. package/src/components/navigation-bar/navigation-bar.css +77 -2
  110. package/src/components/navigation-bar/navigation-bar.js +171 -4
  111. package/src/components/panel/panel.js +13 -2
  112. package/src/components/search/search.js +17 -5
  113. package/src/components/select/field-adapter.js +4 -1
  114. package/src/components/select/select.js +3 -0
  115. package/src/components/sort-control/sort-control.css +17 -0
  116. package/src/components/sort-control/sort-control.js +140 -0
  117. package/src/components/stat-tile/stat-tile.css +213 -0
  118. package/src/components/stat-tile/stat-tile.js +590 -0
  119. package/src/components/tabbox/tabbox.js +12 -1
  120. package/src/components/table/table.css +51 -2
  121. package/src/components/table/table.js +298 -19
  122. package/src/components/table-view/table-view.css +8 -2
  123. package/src/components/table-view/table-view.js +21 -6
  124. package/src/components/tag-picker/tag-picker.css +3 -1
  125. package/src/components/timebox/timebox.js +18 -5
  126. package/src/components/value-list/value-list.js +22 -6
  127. package/src/components/view/record-view.css +5 -0
  128. package/src/components/view/record-view.js +100 -7
  129. package/src/core/component.js +8 -3
  130. package/src/core/icons.js +74 -5
  131. package/src/index.js +33 -3
  132. package/src/internal/app-rail.js +19 -1
  133. package/src/internal/menu-items.js +142 -0
  134. package/styles/print.css +192 -0
  135. package/styles/zx.css +11 -0
  136. package/DESIGN-SYSTEM.md +0 -182
@@ -28,6 +28,24 @@ import { sortRows } from './sort.js';
28
28
  * @property {boolean|unknown[]} [expanded=false] Initially expanded branches: all, none, or ids.
29
29
  */
30
30
 
31
+ /**
32
+ * How one resolved row kind is presented. `rowKinds` is keyed by the kind coerced to a string, so
33
+ * `{ 1: … }` and `{ '1': … }` name the same entry.
34
+ * @typedef {Object} TableRowKind
35
+ * @property {boolean} [span=false] Draw the row as a single cell across every rendered column — a
36
+ * section heading rather than a record. The cell is a `<th scope="colgroup">`, not a `<td>`,
37
+ * because it labels the columns of the rows beneath it and a screen reader walking the table
38
+ * should meet it as a heading rather than as a value. A spanning row carries no selection
39
+ * checkbox, is never editable, and is skipped by `rowReorder`; it still emits `rowclick` and still
40
+ * participates in `getData()` and `updateRow()`.
41
+ * @property {(row: TableRow, index: number) => Node|string|number|null|undefined} [render] Content
42
+ * of the spanning cell. Omitted, the cell falls back to the row's raw value for the first visible
43
+ * column, which is what a host otherwise writes by hand.
44
+ * @property {string} [class] Space-separated classes added to the row, on top of `rowClass`. Zx
45
+ * ships no section look: a billing section is not a kanban swimlane, so the tint, the weight and
46
+ * the rule above it are the host's to decide.
47
+ */
48
+
31
49
  /**
32
50
  * Editing handle handed to a custom `column.editor`.
33
51
  * @typedef {Object} TableEditorApi
@@ -100,6 +118,15 @@ import { sortRows } from './sort.js';
100
118
  * placed as given, and a function is called on each render, which is what to use when the
101
119
  * placeholder holds live controls.
102
120
  * @property {((row: TableRow) => string)|null} [rowClass=null] Additional row class callback.
121
+ * @property {string|((row: TableRow) => unknown)|null} [rowKind=null] Row property or callback
122
+ * naming each row's kind — the `type` on a ZeyOS transaction line, where one value is a position
123
+ * carrying quantities and amounts and another is a section heading that groups the positions under
124
+ * it. `null`, the default, renders every row exactly as before.
125
+ * @property {Record<string, TableRowKind>|null} [rowKinds=null] Presentation per kind, keyed by the
126
+ * resolved kind coerced to a string. A kind with no entry here renders as an ordinary row. While
127
+ * any row in the current data resolves to a `span` kind the header sort controls are withheld and
128
+ * `setSort()` is inert: sorting a grouped table scatters the positions out of their sections, and
129
+ * doing that silently is worse than not offering it. `sortMode: 'server'` still owns its own order.
103
130
  * @property {boolean} [zebra=true] Whether alternate rows use the zebra background.
104
131
  * @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
105
132
  * opens every editable cell of the row together and commits them as a unit. Editing is completely
@@ -258,6 +285,8 @@ export class Table extends Component {
258
285
  height: null,
259
286
  emptyText: null,
260
287
  rowClass: null,
288
+ rowKind: null,
289
+ rowKinds: null,
261
290
  zebra: true,
262
291
  editMode: false,
263
292
  editTrigger: 'double',
@@ -288,6 +317,12 @@ export class Table extends Component {
288
317
  this._original = created ? null : snapshotTarget(root);
289
318
  this._columns = normalizeTableColumns(this.options.columns);
290
319
  this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
320
+ // Row kinds resolve before the first sort: `_sortData()` refuses to run while the data holds a
321
+ // spanning row, and the initial `sort` option reaches it before anything is rendered.
322
+ this._rowKind = normalizeRowKind(this.options.rowKind);
323
+ this._rowKinds = normalizeRowKinds(this.options.rowKinds);
324
+ this._spanning = this._hasSpanningRow();
325
+ this._sortWarned = false;
291
326
  this._selected = new Set();
292
327
  this._selectionAnchorId = null;
293
328
  this._sort = normalizeSort(this.options.sort, this._columns);
@@ -666,6 +701,8 @@ export class Table extends Component {
666
701
  const row = this.getRow(id);
667
702
  const target = this.getRow(targetId);
668
703
  if (!row || !target || Object.is(id, targetId)) return this;
704
+ // A section heading has no handle to grab; refuse the programmatic route for the same reason.
705
+ if (this._isSpanningRow(row)) return this;
669
706
  const next = reorderTableRows(this._data, this.options.rowId, id, targetId, position, this._hierarchy?.parentId);
670
707
  if (!next) return this;
671
708
  const event = this.emit('rowmove', { row, id, target, targetId, position });
@@ -704,6 +741,10 @@ export class Table extends Component {
704
741
  /**
705
742
  * Activates a column sort. Local mode stably reorders data; server mode only updates the
706
743
  * header state and emits the request event.
744
+ *
745
+ * Inert while the data holds a spanning row: a sort would scatter the positions out of the
746
+ * sections that group them, in either sort mode. The header withholds its controls for the same
747
+ * reason, so nothing on screen invites the call.
707
748
  * @param {string} id Column id.
708
749
  * @param {TableSortDirection} [dir] Direction; omitted toggles the current column.
709
750
  * @param {{silent?: boolean}} [options={}] Event options.
@@ -712,6 +753,14 @@ export class Table extends Component {
712
753
  * @fires Table#editcancel
713
754
  */
714
755
  setSort(id, dir, options = {}) {
756
+ if (this._spanning) {
757
+ if (!this._sortWarned) {
758
+ this._sortWarned = true;
759
+ console.warn('[zx.Table] setSort() is inert while the data contains spanning rowKinds rows: '
760
+ + 'sorting would scatter the rows out of the sections that group them.');
761
+ }
762
+ return this;
763
+ }
715
764
  // Server mode never re-renders the body, so the open editor is dropped here rather than in
716
765
  // `_renderBody()`.
717
766
  this._abortEdit({ focus: false });
@@ -769,7 +818,7 @@ export class Table extends Component {
769
818
  */
770
819
  setSelection(ids) {
771
820
  if (!Array.isArray(ids)) throw new TypeError('Table selection must be an array of row ids');
772
- const valid = new Set(this._data.map((row) => this._idFor(row)));
821
+ const valid = this._selectableIds();
773
822
  const next = new Set();
774
823
  if (this.options.selectable !== false) {
775
824
  for (const id of ids) {
@@ -889,6 +938,8 @@ export class Table extends Component {
889
938
  if (!requested) return this;
890
939
  const current = this._data.find((candidate) => Object.is(this._idFor(candidate), rowId));
891
940
  if (current === undefined) return this;
941
+ // A spanning row renders no column cells at all, so there is nothing to open an editor over.
942
+ if (this._isSpanningRow(current)) return this;
892
943
  const column = this._editMode === 'row' && this._hiddenColumns.has(requested.id)
893
944
  ? this._visibleColumns().find((candidate) => resolveEditable(candidate, current) !== false) ?? null
894
945
  : requested;
@@ -1205,6 +1256,45 @@ export class Table extends Component {
1205
1256
  return this._columns.filter((column) => !this._hiddenColumns.has(column.id));
1206
1257
  }
1207
1258
 
1259
+ /**
1260
+ * Whether a row draws as one cell across the table instead of as a record.
1261
+ * @param {TableRow} row Row to classify.
1262
+ * @returns {boolean}
1263
+ */
1264
+ _isSpanningRow(row) {
1265
+ if (this._rowKind === null || this._rowKinds === null) return false;
1266
+ return isSpanningRow(row, this._rowKind, this._rowKinds);
1267
+ }
1268
+
1269
+ /**
1270
+ * Whether any row in the current data — not only the rendered prefix — spans. This is what
1271
+ * withholds the sort controls, and the reader must not be offered a control that the next
1272
+ * `growBy()` would have to take away again.
1273
+ * @returns {boolean}
1274
+ */
1275
+ _hasSpanningRow() {
1276
+ return hasSpanningRow(this._data, this._rowKind, this._rowKinds);
1277
+ }
1278
+
1279
+ /**
1280
+ * How many cells one row occupies: every visible column plus the structural columns the table
1281
+ * renders beside them. Shared by the spanning cell and the empty-state cell so the two can never
1282
+ * disagree about the table's width.
1283
+ * @returns {number}
1284
+ */
1285
+ _spanColspan() {
1286
+ return tableSpanColspan(this._visibleColumns().length, {
1287
+ selectable: this.options.selectable,
1288
+ rowReorder: this.options.rowReorder
1289
+ });
1290
+ }
1291
+
1292
+ /** @returns {TableRow[]} Rendered rows that can carry a selection; spanning rows cannot. */
1293
+ _selectableData() {
1294
+ const visible = this._visibleData();
1295
+ return this._rowKind === null ? visible : visible.filter((row) => !this._isSpanningRow(row));
1296
+ }
1297
+
1208
1298
  /** @returns {HTMLElement} */
1209
1299
  _createColumnControls() {
1210
1300
  const list = h('div', { class: 'zx-table__column-list' });
@@ -1213,7 +1303,8 @@ export class Table extends Component {
1213
1303
  class: 'zx-table__column-status', role: 'status', ariaLive: 'polite'
1214
1304
  });
1215
1305
  const details = h('details', { class: 'zx-table__column-controls' },
1216
- h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }), 'Columns'),
1306
+ h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }),
1307
+ this._message('table.columns', 'Columns', [])),
1217
1308
  list);
1218
1309
  this._renderColumnControls();
1219
1310
  return details;
@@ -1243,7 +1334,8 @@ export class Table extends Component {
1243
1334
  ? h('label', { for: controlId }, toggle, h('span', {}, column.label))
1244
1335
  : h('span', { class: 'zx-table__column-label' }, column.label);
1245
1336
  const order = this.options.columnReorder ? h('span', {
1246
- class: 'zx-table__column-order', role: 'group', ariaLabel: `Move ${column.label}`
1337
+ class: 'zx-table__column-order', role: 'group',
1338
+ ariaLabel: this._message('table.moveColumn', 'Move %1', [column.label])
1247
1339
  },
1248
1340
  h('button', {
1249
1341
  class: 'zx-table__column-move', type: 'button', disabled: index === 0,
@@ -1494,9 +1586,9 @@ export class Table extends Component {
1494
1586
  * click is usually a bulk action. `showAll()` first if you mean all of them.
1495
1587
  */
1496
1588
  if (this._selectAll.checked) {
1497
- for (const dataRow of this._visibleData()) this._selected.add(this._idFor(dataRow));
1589
+ for (const dataRow of this._selectableData()) this._selected.add(this._idFor(dataRow));
1498
1590
  } else {
1499
- for (const dataRow of this._visibleData()) this._selected.delete(this._idFor(dataRow));
1591
+ for (const dataRow of this._selectableData()) this._selected.delete(this._idFor(dataRow));
1500
1592
  }
1501
1593
  this._selectionAnchorId = null;
1502
1594
  this._syncSelection();
@@ -1507,12 +1599,16 @@ export class Table extends Component {
1507
1599
  }
1508
1600
 
1509
1601
  this._headers = new Map();
1602
+ // What this header was built from, so `_renderBody()` can tell whether it is still the right one.
1603
+ this._headerSpanning = this._spanning;
1510
1604
  for (const column of this._visibleColumns()) {
1511
1605
  const th = h('th', { scope: 'col' });
1512
1606
  const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1513
1607
  if (align) th.dataset.align = align;
1514
1608
  if (column.headerTitle) th.title = column.headerTitle;
1515
- if (column.sortable) {
1609
+ // Withheld, not disabled: a header that invites a sort the component will refuse is worse
1610
+ // than a header that never offered one. The label still reads exactly the same.
1611
+ if (column.sortable && !this._spanning) {
1516
1612
  const button = h('button', {
1517
1613
  class: 'zx-table__sort-button',
1518
1614
  type: 'button'
@@ -1537,6 +1633,18 @@ export class Table extends Component {
1537
1633
  // Every data mutation funnels through here, so this is the single place that guarantees no
1538
1634
  // editor component outlives the rows it was rendered into.
1539
1635
  this._abortEdit({ focus: false });
1636
+ /*
1637
+ * And the single place that notices the data gaining or losing its sections. The header owns
1638
+ * the sort controls, so it has to be rebuilt when the answer flips — otherwise a table that was
1639
+ * given grouped data after construction keeps offering a sort it will now refuse, and one whose
1640
+ * sections were filtered away never gets its sort back.
1641
+ *
1642
+ * Compared against what the *header* was built from, not against `_spanning`: `_sortData()`
1643
+ * runs first on every data mutation and has already brought `_spanning` up to date, so
1644
+ * comparing against it would find no change and leave the stale header in place.
1645
+ */
1646
+ this._spanning = this._hasSpanningRow();
1647
+ if (this._headers && this._spanning !== this._headerSpanning) this._renderHeader();
1540
1648
  const fragment = document.createDocumentFragment();
1541
1649
  this._rowMeta = new WeakMap();
1542
1650
  this._rowElements = new Map();
@@ -1546,11 +1654,7 @@ export class Table extends Component {
1546
1654
  this._hierarchyMeta = new Map(projected.map((entry) => [entry.id, entry]));
1547
1655
  if (this._data.length === 0) {
1548
1656
  fragment.append(h('tr', { class: 'zx-table__empty-row' },
1549
- h('td', {
1550
- class: 'zx-table__empty',
1551
- colspan: this._visibleColumns().length + (this.options.selectable === 'multi' ? 1 : 0)
1552
- + (this.options.rowReorder ? 1 : 0)
1553
- }, this._emptyContent())
1657
+ h('td', { class: 'zx-table__empty', colspan: this._spanColspan() }, this._emptyContent())
1554
1658
  ));
1555
1659
  } else {
1556
1660
  // Indices stay absolute: a row's index is its place in the data, not in what is on screen.
@@ -1599,7 +1703,16 @@ export class Table extends Component {
1599
1703
  if (this._growStep) tr.setAttribute('aria-rowindex', String(tree.visibleIndex + 2));
1600
1704
  const rowClass = typeof this.options.rowClass === 'function' ? this.options.rowClass(row) : '';
1601
1705
  if (rowClass) tr.classList.add(...String(rowClass).split(/\s+/).filter(Boolean));
1602
- if (this.options.selectable !== false) tr.setAttribute('aria-selected', String(this._selected.has(id)));
1706
+ const kind = this._rowKind === null ? null : resolveRowKind(row, this._rowKind);
1707
+ const kindSpec = kind === null ? null : resolveRowKindSpec(this._rowKinds, kind);
1708
+ const spanning = Boolean(kindSpec?.span);
1709
+ if (kind !== null) tr.dataset.rowKind = kind;
1710
+ if (kindSpec?.class) tr.classList.add(...String(kindSpec.class).split(/\s+/).filter(Boolean));
1711
+ // A spanning row is a heading, not a record: `aria-selected="false"` would announce it as one
1712
+ // more selectable row that simply is not selected.
1713
+ if (this.options.selectable !== false && !spanning) {
1714
+ tr.setAttribute('aria-selected', String(this._selected.has(id)));
1715
+ }
1603
1716
  if (this._hierarchy) {
1604
1717
  tr.setAttribute('aria-level', String(tree.depth + 1));
1605
1718
  if (tree.hasChildren) tr.setAttribute('aria-expanded', String(this._expanded.has(id)));
@@ -1609,6 +1722,25 @@ export class Table extends Component {
1609
1722
  elements.push(tr);
1610
1723
  this._rowElements.set(id, elements);
1611
1724
 
1725
+ if (spanning) {
1726
+ tr.dataset.span = 'true';
1727
+ /*
1728
+ * One cell, `<th scope="colgroup">`, covering the reorder and selection columns as well as
1729
+ * the data ones: the row labels the columns of the records beneath it, so it belongs in the
1730
+ * accessibility tree as a heading and not as a value — and a second, narrower cell beside it
1731
+ * would put a blank column back into exactly the layout this removes.
1732
+ */
1733
+ const cell = /** @type {HTMLTableCellElement} */ (h('th', {
1734
+ class: 'zx-table__section-cell', scope: 'colgroup', colspan: this._spanColspan()
1735
+ }));
1736
+ const content = spanRowContent(row, kindSpec, this._visibleColumns(), index);
1737
+ if (content && typeof content === 'object' && typeof content.nodeType === 'number') {
1738
+ cell.append(content);
1739
+ } else if (content != null) cell.append(document.createTextNode(String(content)));
1740
+ tr.append(cell);
1741
+ return tr;
1742
+ }
1743
+
1612
1744
  if (this.options.rowReorder) {
1613
1745
  const handle = h('button', {
1614
1746
  class: 'zx-table__row-handle', type: 'button', draggable: true,
@@ -1729,7 +1861,10 @@ export class Table extends Component {
1729
1861
  }
1730
1862
 
1731
1863
  this.emit('rowclick', { ...meta, event });
1732
- if (this.options.selectable === 'single' && !this._selected.has(meta.id)) {
1864
+ // The click still reaches the host — collapsing a section is a reasonable thing to hang off it —
1865
+ // but it does not select the heading, which carries no checkbox in `multi` either.
1866
+ if (this.options.selectable === 'single' && !this._selected.has(meta.id)
1867
+ && !this._isSpanningRow(meta.row)) {
1733
1868
  this._selected = new Set([meta.id]);
1734
1869
  this._selectionAnchorId = meta.id;
1735
1870
  this._syncSelection();
@@ -2093,6 +2228,8 @@ export class Table extends Component {
2093
2228
  if (row < 0 || row >= visible.length) return null;
2094
2229
  const candidateRow = visible[row];
2095
2230
  const candidateColumn = columns[column];
2231
+ // Tab walks past a section heading rather than stopping on a row that has no cells to edit.
2232
+ if (this._isSpanningRow(candidateRow)) continue;
2096
2233
  if (resolveEditable(candidateColumn, candidateRow) !== false) {
2097
2234
  return {
2098
2235
  id: this._idFor(candidateRow),
@@ -2172,6 +2309,9 @@ export class Table extends Component {
2172
2309
  const start = Math.min(anchorIndex, currentIndex);
2173
2310
  const end = Math.max(anchorIndex, currentIndex);
2174
2311
  for (let index = start; index <= end; index += 1) {
2312
+ // Indices stay against the rendered rows so `visibleIndex` still lines up; a section caught
2313
+ // inside the range is stepped over rather than selected.
2314
+ if (this._isSpanningRow(visible[index])) continue;
2175
2315
  const id = this._idFor(visible[index]);
2176
2316
  if (checked) this._selected.add(id);
2177
2317
  else this._selected.delete(id);
@@ -2191,6 +2331,9 @@ export class Table extends Component {
2191
2331
  for (const [id, rows] of this._rowElements) {
2192
2332
  const selected = this._selected.has(id);
2193
2333
  for (const row of rows) {
2334
+ // A spanning row never claimed `aria-selected`; writing it here would tell a screen reader
2335
+ // the heading is a selectable record that happens to be unselected.
2336
+ if (row.hasAttribute('data-span')) continue;
2194
2337
  row.setAttribute('aria-selected', String(selected));
2195
2338
  const checkbox = row.querySelector('.zx-table__row-checkbox');
2196
2339
  if (checkbox) checkbox.checked = selected;
@@ -2202,8 +2345,9 @@ export class Table extends Component {
2202
2345
  /** @returns {void} */
2203
2346
  _syncSelectAll() {
2204
2347
  if (!this._selectAll) return;
2205
- // Measured against what is rendered, so the box reflects the rows the reader can actually see.
2206
- const visible = this._visibleData();
2348
+ // Measured against what is rendered, so the box reflects the rows the reader can actually see —
2349
+ // and only the ones that can be ticked, so a section heading never holds the box indeterminate.
2350
+ const visible = this._selectableData();
2207
2351
  const selectedRows = visible.reduce(
2208
2352
  (count, row) => count + Number(this._selected.has(this._idFor(row))),
2209
2353
  0
@@ -2217,14 +2361,20 @@ export class Table extends Component {
2217
2361
  _syncSortHeader() {
2218
2362
  if (!this._headers) return;
2219
2363
  for (const [id, header] of this._headers) {
2220
- if (this._sort?.id === id) header.setAttribute('aria-sort', this._sort.dir === 'asc' ? 'ascending' : 'descending');
2221
- else header.removeAttribute('aria-sort');
2364
+ // A withheld control must not leave `aria-sort` behind: with the sort inert the attribute
2365
+ // would announce an order the rows are not actually in.
2366
+ if (!this._spanning && this._sort?.id === id) {
2367
+ header.setAttribute('aria-sort', this._sort.dir === 'asc' ? 'ascending' : 'descending');
2368
+ } else header.removeAttribute('aria-sort');
2222
2369
  }
2223
2370
  }
2224
2371
 
2225
2372
  /** @returns {void} */
2226
2373
  _sortData() {
2227
- if (!this._sort) return;
2374
+ // Recomputed here and not only in `_renderBody()`: every data mutation sorts before it renders,
2375
+ // so data that has just gained a section must be seen as grouped before the sort would run.
2376
+ this._spanning = this._hasSpanningRow();
2377
+ if (!this._sort || this._spanning) return;
2228
2378
  const column = this._columns.find((candidate) => candidate.id === this._sort.id);
2229
2379
  if (!column) return;
2230
2380
  const getValue = typeof column.sortValue === 'function' ? column.sortValue : (row) => row?.[column.id];
@@ -2263,9 +2413,18 @@ export class Table extends Component {
2263
2413
  for (const id of this._expanded) if (!valid.has(id)) this._expanded.delete(id);
2264
2414
  }
2265
2415
 
2416
+ /** @returns {Set<unknown>} Ids of every row in the data that may hold a selection. */
2417
+ _selectableIds() {
2418
+ const ids = new Set();
2419
+ for (const row of this._data) {
2420
+ if (!this._isSpanningRow(row)) ids.add(this._idFor(row));
2421
+ }
2422
+ return ids;
2423
+ }
2424
+
2266
2425
  /** @returns {boolean} Whether selection changed. */
2267
2426
  _pruneSelection() {
2268
- const valid = new Set(this._data.map((row) => this._idFor(row)));
2427
+ const valid = this._selectableIds();
2269
2428
  const previousSize = this._selected.size;
2270
2429
  for (const id of this._selected) if (!valid.has(id)) this._selected.delete(id);
2271
2430
  if (!valid.has(this._selectionAnchorId)) this._selectionAnchorId = null;
@@ -2337,6 +2496,104 @@ export class Table extends Component {
2337
2496
  * @type {CustomEvent<TableEditInvalidDetail>}
2338
2497
  */
2339
2498
 
2499
+ /**
2500
+ * Resolves a row's kind through the `rowKind` accessor and coerces it to the string `rowKinds` is
2501
+ * keyed by.
2502
+ *
2503
+ * Only `null` and `undefined` mean "no kind". `0` and `''` are kinds like any other: a ZeyOS
2504
+ * transaction line uses `0` for a position and `1` for a section, so a falsy test here would render
2505
+ * every position as though the table had never been told about kinds at all.
2506
+ * @param {TableRow} row Row to classify.
2507
+ * @param {string|((row: TableRow) => unknown)|null|undefined} rowKind Field name or accessor.
2508
+ * @returns {string|null} The kind as a string, or null when there is none.
2509
+ */
2510
+ export function resolveRowKind(row, rowKind) {
2511
+ if (rowKind == null || rowKind === '') return null;
2512
+ const raw = typeof rowKind === 'function' ? rowKind(row) : row?.[rowKind];
2513
+ return raw == null ? null : String(raw);
2514
+ }
2515
+
2516
+ /**
2517
+ * Looks one resolved kind up in the `rowKinds` map.
2518
+ *
2519
+ * Own properties only: a kind read out of server data could just as easily be `toString` or
2520
+ * `constructor`, and inherited members of `Object.prototype` are not configuration.
2521
+ * @param {Record<string, TableRowKind>|null|undefined} rowKinds Kind map.
2522
+ * @param {string|null} kind Resolved kind.
2523
+ * @returns {TableRowKind|null} Matching entry, or null.
2524
+ */
2525
+ export function resolveRowKindSpec(rowKinds, kind) {
2526
+ if (kind === null || rowKinds == null || typeof rowKinds !== 'object') return null;
2527
+ if (!Object.prototype.hasOwnProperty.call(rowKinds, kind)) return null;
2528
+ const spec = rowKinds[kind];
2529
+ return spec && typeof spec === 'object' ? spec : null;
2530
+ }
2531
+
2532
+ /**
2533
+ * Whether a row draws as one spanning cell under the given configuration.
2534
+ * @param {TableRow} row Row to classify.
2535
+ * @param {string|((row: TableRow) => unknown)|null|undefined} rowKind Field name or accessor.
2536
+ * @param {Record<string, TableRowKind>|null|undefined} rowKinds Kind map.
2537
+ * @returns {boolean}
2538
+ */
2539
+ export function isSpanningRow(row, rowKind, rowKinds) {
2540
+ return Boolean(resolveRowKindSpec(rowKinds, resolveRowKind(row, rowKind))?.span);
2541
+ }
2542
+
2543
+ /**
2544
+ * Whether a data set holds a row that spans — the question that decides whether `Table` offers its
2545
+ * sort controls at all.
2546
+ *
2547
+ * Asked of the whole data rather than of the rendered prefix: with `growing` on, a sort control
2548
+ * that disappeared the moment the reader pressed "show 20 more" would be worse than one that was
2549
+ * never there. Asked again on every data change, so a filter that removes the last section gives
2550
+ * the sort back.
2551
+ * @param {TableRow[]} rows Rows to scan.
2552
+ * @param {string|((row: TableRow) => unknown)|null|undefined} rowKind Field name or accessor.
2553
+ * @param {Record<string, TableRowKind>|null|undefined} rowKinds Kind map.
2554
+ * @returns {boolean}
2555
+ */
2556
+ export function hasSpanningRow(rows, rowKind, rowKinds) {
2557
+ if (!Array.isArray(rows) || rowKind == null || rowKinds == null) return false;
2558
+ return rows.some((row) => isSpanningRow(row, rowKind, rowKinds));
2559
+ }
2560
+
2561
+ /**
2562
+ * How many cells one table row occupies.
2563
+ *
2564
+ * `selectable: 'multi'` and `rowReorder` each render a `<col>` and a leading cell of their own, and
2565
+ * a spanning cell that ignored them would leave the row one or two columns short — the table would
2566
+ * still lay out, silently and wrongly. `selectable: 'single'` adds no column: the whole row is the
2567
+ * control there.
2568
+ * @param {number} columnCount Number of visible data columns.
2569
+ * @param {{selectable?: TableSelectionMode, rowReorder?: boolean}} [structure={}] Structural columns.
2570
+ * @returns {number} Colspan covering every rendered cell, never below 1.
2571
+ */
2572
+ export function tableSpanColspan(columnCount, structure = {}) {
2573
+ const columns = Number.isFinite(Number(columnCount)) ? Math.max(0, Math.trunc(Number(columnCount))) : 0;
2574
+ const extras = (structure.selectable === 'multi' ? 1 : 0) + (structure.rowReorder ? 1 : 0);
2575
+ return Math.max(1, columns + extras);
2576
+ }
2577
+
2578
+ /**
2579
+ * Content for a spanning row's single cell.
2580
+ *
2581
+ * Without a `render` the cell shows the row's raw value for the first visible column — the same
2582
+ * thing a host writes by hand today. Deliberately raw rather than routed through the column's own
2583
+ * renderer or display type: that renderer was written for a record, and running it against a
2584
+ * heading that carries no quantities or amounts is how a section row ends up showing `0,00 €`.
2585
+ * @param {TableRow} row Row being rendered.
2586
+ * @param {TableRowKind|null|undefined} spec Resolved kind presentation.
2587
+ * @param {TableColumn[]} visibleColumns Columns currently rendered, in display order.
2588
+ * @param {number} [index=0] Flat row index.
2589
+ * @returns {Node|string|number|null|undefined} Cell content.
2590
+ */
2591
+ export function spanRowContent(row, spec, visibleColumns, index = 0) {
2592
+ if (typeof spec?.render === 'function') return spec.render(row, index);
2593
+ const first = visibleColumns?.[0];
2594
+ return first ? row?.[first.id] : null;
2595
+ }
2596
+
2340
2597
  /**
2341
2598
  * Applies a column's built-in display type. A custom renderer always wins and may still return a
2342
2599
  * Node; all built-in results are strings so `_renderCellContent` can insert them as text.
@@ -2724,6 +2981,28 @@ function normalizeHierarchy(option, columns) {
2724
2981
  return { ...option, parentId, column };
2725
2982
  }
2726
2983
 
2984
+ /**
2985
+ * Normalizes the `rowKind` accessor. Anything that is not a non-empty field name or a callback is
2986
+ * `null`, which is the switch that keeps every existing table on exactly its old rendering path.
2987
+ * @param {unknown} option Configured accessor.
2988
+ * @returns {string|((row: TableRow) => unknown)|null}
2989
+ */
2990
+ function normalizeRowKind(option) {
2991
+ if (typeof option === 'function') return /** @type {(row: TableRow) => unknown} */ (option);
2992
+ return typeof option === 'string' && option !== '' ? option : null;
2993
+ }
2994
+
2995
+ /**
2996
+ * Normalizes the `rowKinds` map. An array, a function or a primitive is not a keyed map and is
2997
+ * discarded rather than half-read.
2998
+ * @param {unknown} option Configured kind map.
2999
+ * @returns {Record<string, TableRowKind>|null}
3000
+ */
3001
+ function normalizeRowKinds(option) {
3002
+ if (!option || typeof option !== 'object' || Array.isArray(option)) return null;
3003
+ return /** @type {Record<string, TableRowKind>} */ (option);
3004
+ }
3005
+
2727
3006
  /** @param {unknown} option @returns {Set<unknown>} */
2728
3007
  function initialExpanded(option) {
2729
3008
  return new Set(Array.isArray(option) ? option : []);
@@ -6,13 +6,19 @@
6
6
  min-inline-size: 0;
7
7
  }
8
8
 
9
- .zx-table-view[data-loading="true"] .zx-table__column-controls {
9
+ .zx-table-view__toolbar {
10
+ min-inline-size: 0;
11
+ }
12
+
13
+ .zx-table-view[data-loading="true"] .zx-table__column-controls,
14
+ .zx-table-view[data-loading="true"] .zx-record-view__field-controls {
10
15
  pointer-events: none;
11
16
  opacity: .62;
12
17
  }
13
18
 
14
19
  @media (prefers-reduced-motion: no-preference) {
15
- .zx-table-view .zx-table__column-controls {
20
+ .zx-table-view .zx-table__column-controls,
21
+ .zx-table-view .zx-record-view__field-controls {
16
22
  transition: opacity var(--zx-dur-fast) var(--zx-ease);
17
23
  }
18
24
  }
@@ -1,7 +1,7 @@
1
1
  // @ts-check
2
2
  import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
3
3
  import { formatTableCell, Table } from '../table/table.js';
4
- import { RecordView, readViewField } from '../view/record-view.js';
4
+ import { normalizeViewFieldControls, RecordView, readViewField } from '../view/record-view.js';
5
5
 
6
6
  /** @typedef {Record<string, any>} TableViewRecord */
7
7
  /** @typedef {import('../view/record-view.js').ViewField} ViewField */
@@ -20,7 +20,10 @@ import { RecordView, readViewField } from '../view/record-view.js';
20
20
  * @property {unknown[]} [selection=[]] Initially selected record ids.
21
21
  * @property {string[]} [fieldOrder=[]] Preferred stable field order.
22
22
  * @property {string[]} [hiddenFields=[]] Initially hidden field ids.
23
- * @property {boolean} [fieldControls=true] Show the accessible visibility and reorder disclosure.
23
+ * @property {boolean|import('../view/record-view.js').ViewFieldControlsConfig} [fieldControls=true]
24
+ * Show the accessible visibility and reorder disclosure. `true` keeps the composed Table's own
25
+ * column chooser and `false` omits it; an object renders the shared record-view chooser instead,
26
+ * with a translated `label` and, with `target`, mounted into a host element.
24
27
  * @property {string|Node|(()=>string|Node)|null} [emptyText=null] Empty-result content.
25
28
  * @property {TableOptions} [table={}] Advanced low-level Table options. Shared options win where
26
29
  * their contracts overlap; all other options and callbacks are forwarded unchanged.
@@ -74,9 +77,15 @@ export class TableView extends RecordView {
74
77
  this._tableViewDestroyed = false;
75
78
  this._tableHost = h('div', { class: 'zx-table-view__table' });
76
79
  this._initRecordView(root);
80
+ // Only a `fieldControls` object replaces Table's own chooser, and only an unresolved target
81
+ // keeps that replacement inside the view — so `true` and `false` render exactly as before.
82
+ const fieldControls = normalizeViewFieldControls(this.options.fieldControls);
83
+ this._tableViewToolbar = fieldControls.custom && !this._viewFieldControlsTarget
84
+ ? h('div', { class: 'zx-record-view__toolbar zx-table-view__toolbar' })
85
+ : null;
77
86
 
78
87
  try {
79
- root.replaceChildren(this._tableHost);
88
+ root.replaceChildren(...[this._tableViewToolbar, this._tableHost].filter(Boolean));
80
89
  const viewOptions = /** @type {Readonly<TableViewOptions>} */ (this.options);
81
90
  const options = tableOptionsForView(viewOptions.table, {
82
91
  fields: this.getFields(),
@@ -93,9 +102,11 @@ export class TableView extends RecordView {
93
102
  // Seed selection before bridging events so construction stays silent like RecordView itself.
94
103
  this.table.setSelection(this.getSelectionIds());
95
104
  this._bridgeTableEvents();
105
+ if (fieldControls.custom) this._mountViewFieldControls(this._tableViewToolbar ?? root);
96
106
  return root;
97
107
  } catch (error) {
98
108
  this.table?.destroy();
109
+ this._removeViewFieldControls();
99
110
  if (!created) restoreTarget(root, this._tableViewOriginal);
100
111
  throw error;
101
112
  }
@@ -302,10 +313,14 @@ export function fieldsToTableColumns(fields) {
302
313
  * Builds the composed Table configuration. Shared options deliberately overwrite overlapping
303
314
  * low-level options; every advanced option remains untouched.
304
315
  * @param {TableOptions|undefined} table Advanced Table options.
305
- * @param {{fields:ViewField[],data:TableViewRecord[],recordId:string|((record:TableViewRecord)=>unknown),sort:ViewSort|null,sortMode:'local'|'server',selectable:false|'single'|'multi',hiddenFields:string[],fieldControls:boolean,emptyText:string|Node|(()=>string|Node)|null}} shared Shared state.
316
+ * @param {{fields:ViewField[],data:TableViewRecord[],recordId:string|((record:TableViewRecord)=>unknown),sort:ViewSort|null,sortMode:'local'|'server',selectable:false|'single'|'multi',hiddenFields:string[],fieldControls:boolean|import('../view/record-view.js').ViewFieldControlsConfig,emptyText:string|Node|(()=>string|Node)|null}} shared Shared state.
306
317
  * @returns {TableOptions} Table configuration.
307
318
  */
308
319
  export function tableOptionsForView(table, shared) {
320
+ // A customised chooser is rendered by TableView from the shared field state, so Table's own one
321
+ // stands down; `true`/`false` keep Table's chooser exactly as it has always been.
322
+ const controls = normalizeViewFieldControls(shared.fieldControls);
323
+ const tableChooser = controls.enabled && !controls.custom;
309
324
  return {
310
325
  ...(table && typeof table === 'object' ? table : {}),
311
326
  columns: fieldsToTableColumns(shared.fields),
@@ -315,8 +330,8 @@ export function tableOptionsForView(table, shared) {
315
330
  sortMode: shared.sortMode,
316
331
  selectable: shared.selectable,
317
332
  hiddenColumns: [...shared.hiddenFields],
318
- columnVisibility: Boolean(shared.fieldControls),
319
- columnReorder: Boolean(shared.fieldControls),
333
+ columnVisibility: tableChooser,
334
+ columnReorder: tableChooser,
320
335
  emptyText: shared.emptyText
321
336
  };
322
337
  }
@@ -10,7 +10,9 @@
10
10
  align-items: center;
11
11
  gap: var(--zx-space-1);
12
12
  min-block-size: var(--zx-control-height);
13
- padding: 3px 3px 3px var(--zx-space-2);
13
+ padding: 3px;
14
+ /* The tags sit further from the field's leading edge than from its other three. */
15
+ padding-inline-start: var(--zx-space-2);
14
16
  border: 1px solid var(--zx-color-border-control);
15
17
  border-radius: var(--zx-control-radius);
16
18
  background: var(--zx-color-bg-control);