@zeyos/zx 4.3.4 → 5.0.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 (231) hide show
  1. package/CHANGELOG.md +438 -0
  2. package/README.md +49 -10
  3. package/dist/types/components/activity-item/activity-item.d.ts +351 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +217 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +33 -3
  6. package/dist/types/components/avatar/avatar.d.ts +46 -7
  7. package/dist/types/components/badge/badge.d.ts +21 -1
  8. package/dist/types/components/button/button.d.ts +23 -0
  9. package/dist/types/components/calendar/calendar.d.ts +19 -0
  10. package/dist/types/components/card/card.d.ts +7 -0
  11. package/dist/types/components/card-view/card-view.d.ts +11 -3
  12. package/dist/types/components/card-view/record-card.d.ts +7 -1
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +149 -0
  16. package/dist/types/components/code-editor/field-adapter.d.ts +5 -0
  17. package/dist/types/components/context-menu/context-menu.d.ts +18 -2
  18. package/dist/types/components/date-picker/date-picker.d.ts +8 -0
  19. package/dist/types/components/date-picker/time-picker.d.ts +7 -0
  20. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  21. package/dist/types/components/datebox/datebox.d.ts +7 -2
  22. package/dist/types/components/dialog/dialog.d.ts +75 -6
  23. package/dist/types/components/dropdown/dropdown.d.ts +2 -2
  24. package/dist/types/components/entity-ref/entity-ref.d.ts +331 -0
  25. package/dist/types/components/field/field.d.ts +24 -2
  26. package/dist/types/components/fieldset/fieldset.d.ts +19 -1
  27. package/dist/types/components/file-item/file-item.d.ts +262 -0
  28. package/dist/types/components/file-list/file-list.d.ts +171 -0
  29. package/dist/types/components/filter/filter.d.ts +91 -36
  30. package/dist/types/components/filter-panel/filter-panel.d.ts +449 -0
  31. package/dist/types/components/form/form.d.ts +12 -1
  32. package/dist/types/components/kanban-view/kanban-card.d.ts +146 -0
  33. package/dist/types/components/kanban-view/kanban-view.d.ts +170 -4
  34. package/dist/types/components/launcher/launcher.d.ts +278 -26
  35. package/dist/types/components/list-toolbar/list-toolbar.d.ts +599 -0
  36. package/dist/types/components/menu-button/menu-button.d.ts +37 -42
  37. package/dist/types/components/message/message.d.ts +126 -17
  38. package/dist/types/components/modal/modal.d.ts +61 -2
  39. package/dist/types/components/navigation-bar/navigation-bar.d.ts +105 -21
  40. package/dist/types/components/panel/panel.d.ts +7 -0
  41. package/dist/types/components/search/search.d.ts +7 -0
  42. package/dist/types/components/select/color.d.ts +12 -0
  43. package/dist/types/components/select/glyphs.d.ts +124 -0
  44. package/dist/types/components/select/select.d.ts +15 -3
  45. package/dist/types/components/sort-control/sort-control.d.ts +105 -0
  46. package/dist/types/components/stat-tile/stat-tile.d.ts +288 -0
  47. package/dist/types/components/tabbox/tabbox.d.ts +16 -0
  48. package/dist/types/components/table/table.d.ts +190 -1
  49. package/dist/types/components/table-view/table-view.d.ts +11 -5
  50. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  51. package/dist/types/components/timebox/timebox.d.ts +7 -0
  52. package/dist/types/components/tree/tree.d.ts +13 -0
  53. package/dist/types/components/value-list/value-list.d.ts +8 -0
  54. package/dist/types/components/view/record-view.d.ts +96 -5
  55. package/dist/types/core/breakpoint.d.ts +1 -1
  56. package/dist/types/core/icons.d.ts +37 -22
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +15 -1
  59. package/dist/types/index.d.ts +16 -3
  60. package/dist/types/internal/app-rail.d.ts +10 -0
  61. package/dist/types/internal/menu-items.d.ts +134 -0
  62. package/dist/types/zeyos/connect.d.ts +6 -0
  63. package/dist/types/zeyos/form.d.ts +50 -9
  64. package/dist/types/zeyos/index.d.ts +1 -1
  65. package/dist/types/zeyos/query.d.ts +79 -0
  66. package/dist/types/zeyos/schema.d.ts +24 -1
  67. package/dist/types/zeyos/table.d.ts +100 -14
  68. package/dist/types/zeyos/view.d.ts +10 -0
  69. package/dist/zx-zeyos.esm.js +2206 -489
  70. package/dist/zx-zeyos.esm.min.js +56 -1
  71. package/dist/zx.css +3322 -724
  72. package/dist/zx.esm.js +12244 -5809
  73. package/dist/zx.esm.min.js +58 -3
  74. package/dist/zx.global.js +12246 -5811
  75. package/dist/zx.global.min.js +58 -3
  76. package/dist/zx.min.css +1 -1
  77. package/docs/api.json +1683 -146
  78. package/docs/llms.md +777 -110
  79. package/docs/llms.txt +14 -8
  80. package/package.json +1 -1
  81. package/src/components/account-menu/account-menu.css +35 -10
  82. package/src/components/activity-item/activity-item.css +137 -0
  83. package/src/components/activity-item/activity-item.js +602 -0
  84. package/src/components/activity-list/activity-list.css +99 -0
  85. package/src/components/activity-list/activity-list.js +409 -0
  86. package/src/components/app-icon/app-icon.css +1 -1
  87. package/src/components/app-rail/app-rail.css +191 -22
  88. package/src/components/app-sidebar/app-sidebar.css +349 -24
  89. package/src/components/app-sidebar/app-sidebar.js +86 -11
  90. package/src/components/aurora/aurora.css +5 -3
  91. package/src/components/avatar/avatar.css +32 -7
  92. package/src/components/avatar/avatar.js +72 -8
  93. package/src/components/badge/badge.css +54 -2
  94. package/src/components/badge/badge.js +29 -2
  95. package/src/components/button/button.css +74 -8
  96. package/src/components/button/button.js +44 -1
  97. package/src/components/calendar/calendar.css +46 -46
  98. package/src/components/calendar/calendar.js +33 -3
  99. package/src/components/card/card.css +2 -2
  100. package/src/components/card/card.js +12 -1
  101. package/src/components/card-view/card-view.css +10 -7
  102. package/src/components/card-view/card-view.js +25 -9
  103. package/src/components/card-view/record-card.js +7 -3
  104. package/src/components/chart/chart.css +4 -1
  105. package/src/components/chart/chart.js +12 -0
  106. package/src/components/check-button/check-button.css +10 -2
  107. package/src/components/checklist/checklist.css +9 -2
  108. package/src/components/checklist/checklist.js +11 -5
  109. package/src/components/checklist/field-adapter.js +3 -1
  110. package/src/components/code-editor/code-editor.css +68 -0
  111. package/src/components/code-editor/code-editor.js +306 -0
  112. package/src/components/code-editor/field-adapter.js +34 -0
  113. package/src/components/context-menu/context-menu.css +87 -5
  114. package/src/components/context-menu/context-menu.js +22 -30
  115. package/src/components/copy/copy-button.css +6 -0
  116. package/src/components/copy/copy-input.css +8 -3
  117. package/src/components/data-filter/data-filter.css +5 -4
  118. package/src/components/date-picker/date-picker.css +6 -6
  119. package/src/components/date-picker/date-picker.js +22 -8
  120. package/src/components/date-picker/date-range-picker.css +3 -3
  121. package/src/components/date-picker/month-picker.js +3 -3
  122. package/src/components/date-picker/time-picker.css +1 -1
  123. package/src/components/date-picker/time-picker.js +20 -8
  124. package/src/components/datebox/date-range-box.css +79 -2
  125. package/src/components/datebox/date-range-box.js +13 -2
  126. package/src/components/datebox/datebox.css +79 -2
  127. package/src/components/datebox/datebox.js +13 -2
  128. package/src/components/dialog/dialog.css +26 -0
  129. package/src/components/dialog/dialog.js +174 -70
  130. package/src/components/dropdown/dropdown.css +1 -1
  131. package/src/components/dropdown/dropdown.js +7 -5
  132. package/src/components/empty-state/empty-state.css +2 -2
  133. package/src/components/entity-ref/entity-ref.css +182 -0
  134. package/src/components/entity-ref/entity-ref.js +496 -0
  135. package/src/components/field/field.css +28 -9
  136. package/src/components/field/field.js +68 -26
  137. package/src/components/field-upload/field-upload.css +1 -1
  138. package/src/components/fieldset/fieldset.css +5 -3
  139. package/src/components/fieldset/fieldset.js +28 -5
  140. package/src/components/file-item/file-item.css +88 -0
  141. package/src/components/file-item/file-item.js +505 -0
  142. package/src/components/file-list/file-list.css +51 -0
  143. package/src/components/file-list/file-list.js +320 -0
  144. package/src/components/filter/filter.css +27 -1
  145. package/src/components/filter/filter.js +205 -47
  146. package/src/components/filter-panel/filter-panel.css +104 -0
  147. package/src/components/filter-panel/filter-panel.js +964 -0
  148. package/src/components/finder/finder.css +15 -13
  149. package/src/components/form/form.css +5 -4
  150. package/src/components/form/form.js +11 -2
  151. package/src/components/grid/grid.css +1 -1
  152. package/src/components/groupbox/groupbox.css +1 -1
  153. package/src/components/kanban-view/kanban-card.js +247 -0
  154. package/src/components/kanban-view/kanban-view.css +226 -8
  155. package/src/components/kanban-view/kanban-view.js +255 -12
  156. package/src/components/launcher/launcher.css +107 -25
  157. package/src/components/launcher/launcher.js +557 -63
  158. package/src/components/list-toolbar/list-toolbar.css +194 -0
  159. package/src/components/list-toolbar/list-toolbar.js +851 -0
  160. package/src/components/master-panel/master-panel.css +2 -0
  161. package/src/components/menu-button/menu-button.css +87 -5
  162. package/src/components/menu-button/menu-button.js +86 -46
  163. package/src/components/message/message.css +38 -5
  164. package/src/components/message/message.js +230 -17
  165. package/src/components/modal/modal.css +15 -0
  166. package/src/components/modal/modal.js +159 -3
  167. package/src/components/navigation-bar/navigation-bar.css +95 -11
  168. package/src/components/navigation-bar/navigation-bar.js +171 -4
  169. package/src/components/number-field/field-adapter.js +1 -0
  170. package/src/components/number-field/number-field.css +70 -0
  171. package/src/components/number-field/number-field.js +2 -0
  172. package/src/components/pagination/pagination.css +8 -6
  173. package/src/components/panel/panel.css +1 -1
  174. package/src/components/panel/panel.js +13 -2
  175. package/src/components/rating/rating.css +7 -0
  176. package/src/components/search/search.js +17 -5
  177. package/src/components/select/color.js +22 -0
  178. package/src/components/select/field-adapter.js +6 -1
  179. package/src/components/select/glyphs.js +165 -0
  180. package/src/components/select/select.css +138 -45
  181. package/src/components/select/select.js +86 -47
  182. package/src/components/sheet/sheet.css +11 -8
  183. package/src/components/slider/slider.css +16 -2
  184. package/src/components/slider/slider.js +1 -1
  185. package/src/components/sort-control/sort-control.css +20 -0
  186. package/src/components/sort-control/sort-control.js +140 -0
  187. package/src/components/stat-tile/stat-tile.css +208 -0
  188. package/src/components/stat-tile/stat-tile.js +601 -0
  189. package/src/components/tabbox/tabbox.css +32 -27
  190. package/src/components/tabbox/tabbox.js +17 -2
  191. package/src/components/table/table.css +155 -31
  192. package/src/components/table/table.js +386 -33
  193. package/src/components/table-view/table-view.css +8 -2
  194. package/src/components/table-view/table-view.js +21 -6
  195. package/src/components/tag-picker/tag-picker.css +86 -6
  196. package/src/components/tag-picker/tag-picker.js +25 -8
  197. package/src/components/timebox/timebox.css +22 -2
  198. package/src/components/timebox/timebox.js +18 -5
  199. package/src/components/toggle/toggle.css +12 -1
  200. package/src/components/toolbar/toolbar.js +2 -2
  201. package/src/components/tooltip/tooltip.css +4 -0
  202. package/src/components/tooltip/tooltip.js +10 -3
  203. package/src/components/tree/tree.css +25 -16
  204. package/src/components/tree/tree.js +31 -0
  205. package/src/components/value-list/value-list.css +3 -3
  206. package/src/components/value-list/value-list.js +22 -6
  207. package/src/components/view/record-view.css +5 -0
  208. package/src/components/view/record-view.js +100 -7
  209. package/src/core/breakpoint.js +1 -1
  210. package/src/core/icons.js +259 -57
  211. package/src/core/motion.js +147 -0
  212. package/src/core/overlay-host.js +21 -2
  213. package/src/index.js +30 -3
  214. package/src/internal/app-rail.js +18 -1
  215. package/src/internal/menu-items.js +142 -0
  216. package/src/zeyos/connect.js +6 -2
  217. package/src/zeyos/form.js +67 -27
  218. package/src/zeyos/index.js +1 -1
  219. package/src/zeyos/query.js +153 -0
  220. package/src/zeyos/schema.js +144 -27
  221. package/src/zeyos/table.js +68 -5
  222. package/src/zeyos/view.js +6 -0
  223. package/styles/base.css +56 -0
  224. package/styles/icons.css +11 -1
  225. package/styles/print.css +192 -0
  226. package/styles/tokens/dark.css +113 -37
  227. package/styles/tokens/global.css +121 -27
  228. package/styles/tokens/semantic.css +100 -31
  229. package/styles/tokens/themes.css +7 -6
  230. package/styles/zx.css +11 -1
  231. package/styles/tokens/density.css +0 -9
@@ -15,6 +15,7 @@ import { sortRows } from './sort.js';
15
15
  /** @typedef {'asc'|'desc'} TableSortDirection */
16
16
  /** @typedef {false|'single'|'multi'} TableSelectionMode */
17
17
  /** @typedef {false|'cell'|'row'} TableEditMode */
18
+ /** @typedef {'default'|'plain'} TableVariant */
18
19
  /** @typedef {'text'|'number'|'select'|'date'|'checkbox'|'textarea'|'custom'} TableEditorType */
19
20
  /** @typedef {'text'|'number'|'currency'|'percent'|'unit'} TableColumnType */
20
21
  /** @typedef {{value: unknown, label: string}} TableEditorOption */
@@ -28,6 +29,24 @@ import { sortRows } from './sort.js';
28
29
  * @property {boolean|unknown[]} [expanded=false] Initially expanded branches: all, none, or ids.
29
30
  */
30
31
 
32
+ /**
33
+ * How one resolved row kind is presented. `rowKinds` is keyed by the kind coerced to a string, so
34
+ * `{ 1: … }` and `{ '1': … }` name the same entry.
35
+ * @typedef {Object} TableRowKind
36
+ * @property {boolean} [span=false] Draw the row as a single cell across every rendered column — a
37
+ * section heading rather than a record. The cell is a `<th scope="colgroup">`, not a `<td>`,
38
+ * because it labels the columns of the rows beneath it and a screen reader walking the table
39
+ * should meet it as a heading rather than as a value. A spanning row carries no selection
40
+ * checkbox, is never editable, and is skipped by `rowReorder`; it still emits `rowclick` and still
41
+ * participates in `getData()` and `updateRow()`.
42
+ * @property {(row: TableRow, index: number) => Node|string|number|null|undefined} [render] Content
43
+ * of the spanning cell. Omitted, the cell falls back to the row's raw value for the first visible
44
+ * column, which is what a host otherwise writes by hand.
45
+ * @property {string} [class] Space-separated classes added to the row, on top of `rowClass`. Zx
46
+ * ships no section look: a billing section is not a kanban swimlane, so the tint, the weight and
47
+ * the rule above it are the host's to decide.
48
+ */
49
+
31
50
  /**
32
51
  * Editing handle handed to a custom `column.editor`.
33
52
  * @typedef {Object} TableEditorApi
@@ -41,7 +60,11 @@ import { sortRows } from './sort.js';
41
60
  /**
42
61
  * @typedef {Object} TableColumn
43
62
  * @property {string} id Row property and stable column identifier.
44
- * @property {string} label Visible header label.
63
+ * @property {string} label Visible header label. An empty label leaves the header blank — an
64
+ * icon or actions column — and the header is then named by `ariaLabel`, or by the id.
65
+ * @property {string} [ariaLabel] Accessible name of the column header, and the name the column
66
+ * chooser and the cell editors use. Needed when `label` is empty or terse (`'#'`); include the
67
+ * visible label's words in it when there is one.
45
68
  * @property {boolean} [sortable=false] Whether the header changes the active sort.
46
69
  * @property {string} [width='auto'] CSS width, `auto`, or an `fr` proportion. `fr` values are
47
70
  * converted into proportional percentages on `<col>` elements and enable `table-layout: fixed`;
@@ -100,7 +123,19 @@ import { sortRows } from './sort.js';
100
123
  * placed as given, and a function is called on each render, which is what to use when the
101
124
  * placeholder holds live controls.
102
125
  * @property {((row: TableRow) => string)|null} [rowClass=null] Additional row class callback.
126
+ * @property {string|((row: TableRow) => unknown)|null} [rowKind=null] Row property or callback
127
+ * naming each row's kind — the `type` on a ZeyOS transaction line, where one value is a position
128
+ * carrying quantities and amounts and another is a section heading that groups the positions under
129
+ * it. `null`, the default, renders every row exactly as before.
130
+ * @property {Record<string, TableRowKind>|null} [rowKinds=null] Presentation per kind, keyed by the
131
+ * resolved kind coerced to a string. A kind with no entry here renders as an ordinary row. While
132
+ * any row in the current data resolves to a `span` kind the header sort controls are withheld and
133
+ * `setSort()` is inert: sorting a grouped table scatters the positions out of their sections, and
134
+ * doing that silently is worse than not offering it. `sortMode: 'server'` still owns its own order.
103
135
  * @property {boolean} [zebra=true] Whether alternate rows use the zebra background.
136
+ * @property {TableVariant} [variant='default'] Frame treatment. `'default'` draws the table's own
137
+ * bordered box; `'plain'` is for a table inside a card or panel that already draws one — no outer
138
+ * frame, no header band, no zebra, hairline row dividers. Reflected as `data-variant` on the root.
104
139
  * @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
105
140
  * opens every editable cell of the row together and commits them as a unit. Editing is completely
106
141
  * inert while this is `false`.
@@ -258,7 +293,10 @@ export class Table extends Component {
258
293
  height: null,
259
294
  emptyText: null,
260
295
  rowClass: null,
296
+ rowKind: null,
297
+ rowKinds: null,
261
298
  zebra: true,
299
+ variant: 'default',
262
300
  editMode: false,
263
301
  editTrigger: 'double',
264
302
  hierarchy: false,
@@ -288,6 +326,12 @@ export class Table extends Component {
288
326
  this._original = created ? null : snapshotTarget(root);
289
327
  this._columns = normalizeTableColumns(this.options.columns);
290
328
  this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
329
+ // Row kinds resolve before the first sort: `_sortData()` refuses to run while the data holds a
330
+ // spanning row, and the initial `sort` option reaches it before anything is rendered.
331
+ this._rowKind = normalizeRowKind(this.options.rowKind);
332
+ this._rowKinds = normalizeRowKinds(this.options.rowKinds);
333
+ this._spanning = this._hasSpanningRow();
334
+ this._sortWarned = false;
291
335
  this._selected = new Set();
292
336
  this._selectionAnchorId = null;
293
337
  this._sort = normalizeSort(this.options.sort, this._columns);
@@ -326,6 +370,8 @@ export class Table extends Component {
326
370
  }
327
371
  this._reconcileExpanded();
328
372
 
373
+ if (normalizeTableVariant(this.options.variant) === 'plain') root.dataset.variant = 'plain';
374
+
329
375
  this._table = h('table', { class: 'zx-table__table' });
330
376
  if (this._hierarchy) this._table.setAttribute('role', 'treegrid');
331
377
  this._colgroup = h('colgroup', { class: 'zx-table__columns' });
@@ -603,7 +649,7 @@ export class Table extends Component {
603
649
  const column = this._columns.find((candidate) => candidate.id === String(id));
604
650
  const index = this.getColumnOrder().indexOf(String(id));
605
651
  if (column && index >= 0) {
606
- this._columnStatus.textContent = `Moved ${column.label} to position ${index + 1} of ${this._columns.length}.`;
652
+ this._columnStatus.textContent = `Moved ${tableColumnName(column)} to position ${index + 1} of ${this._columns.length}.`;
607
653
  }
608
654
  }
609
655
  return this;
@@ -666,6 +712,8 @@ export class Table extends Component {
666
712
  const row = this.getRow(id);
667
713
  const target = this.getRow(targetId);
668
714
  if (!row || !target || Object.is(id, targetId)) return this;
715
+ // A section heading has no handle to grab; refuse the programmatic route for the same reason.
716
+ if (this._isSpanningRow(row)) return this;
669
717
  const next = reorderTableRows(this._data, this.options.rowId, id, targetId, position, this._hierarchy?.parentId);
670
718
  if (!next) return this;
671
719
  const event = this.emit('rowmove', { row, id, target, targetId, position });
@@ -704,6 +752,10 @@ export class Table extends Component {
704
752
  /**
705
753
  * Activates a column sort. Local mode stably reorders data; server mode only updates the
706
754
  * header state and emits the request event.
755
+ *
756
+ * Inert while the data holds a spanning row: a sort would scatter the positions out of the
757
+ * sections that group them, in either sort mode. The header withholds its controls for the same
758
+ * reason, so nothing on screen invites the call.
707
759
  * @param {string} id Column id.
708
760
  * @param {TableSortDirection} [dir] Direction; omitted toggles the current column.
709
761
  * @param {{silent?: boolean}} [options={}] Event options.
@@ -712,6 +764,14 @@ export class Table extends Component {
712
764
  * @fires Table#editcancel
713
765
  */
714
766
  setSort(id, dir, options = {}) {
767
+ if (this._spanning) {
768
+ if (!this._sortWarned) {
769
+ this._sortWarned = true;
770
+ console.warn('[zx.Table] setSort() is inert while the data contains spanning rowKinds rows: '
771
+ + 'sorting would scatter the rows out of the sections that group them.');
772
+ }
773
+ return this;
774
+ }
715
775
  // Server mode never re-renders the body, so the open editor is dropped here rather than in
716
776
  // `_renderBody()`.
717
777
  this._abortEdit({ focus: false });
@@ -769,7 +829,7 @@ export class Table extends Component {
769
829
  */
770
830
  setSelection(ids) {
771
831
  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)));
832
+ const valid = this._selectableIds();
773
833
  const next = new Set();
774
834
  if (this.options.selectable !== false) {
775
835
  for (const id of ids) {
@@ -889,6 +949,8 @@ export class Table extends Component {
889
949
  if (!requested) return this;
890
950
  const current = this._data.find((candidate) => Object.is(this._idFor(candidate), rowId));
891
951
  if (current === undefined) return this;
952
+ // A spanning row renders no column cells at all, so there is nothing to open an editor over.
953
+ if (this._isSpanningRow(current)) return this;
892
954
  const column = this._editMode === 'row' && this._hiddenColumns.has(requested.id)
893
955
  ? this._visibleColumns().find((candidate) => resolveEditable(candidate, current) !== false) ?? null
894
956
  : requested;
@@ -1205,6 +1267,45 @@ export class Table extends Component {
1205
1267
  return this._columns.filter((column) => !this._hiddenColumns.has(column.id));
1206
1268
  }
1207
1269
 
1270
+ /**
1271
+ * Whether a row draws as one cell across the table instead of as a record.
1272
+ * @param {TableRow} row Row to classify.
1273
+ * @returns {boolean}
1274
+ */
1275
+ _isSpanningRow(row) {
1276
+ if (this._rowKind === null || this._rowKinds === null) return false;
1277
+ return isSpanningRow(row, this._rowKind, this._rowKinds);
1278
+ }
1279
+
1280
+ /**
1281
+ * Whether any row in the current data — not only the rendered prefix — spans. This is what
1282
+ * withholds the sort controls, and the reader must not be offered a control that the next
1283
+ * `growBy()` would have to take away again.
1284
+ * @returns {boolean}
1285
+ */
1286
+ _hasSpanningRow() {
1287
+ return hasSpanningRow(this._data, this._rowKind, this._rowKinds);
1288
+ }
1289
+
1290
+ /**
1291
+ * How many cells one row occupies: every visible column plus the structural columns the table
1292
+ * renders beside them. Shared by the spanning cell and the empty-state cell so the two can never
1293
+ * disagree about the table's width.
1294
+ * @returns {number}
1295
+ */
1296
+ _spanColspan() {
1297
+ return tableSpanColspan(this._visibleColumns().length, {
1298
+ selectable: this.options.selectable,
1299
+ rowReorder: this.options.rowReorder
1300
+ });
1301
+ }
1302
+
1303
+ /** @returns {TableRow[]} Rendered rows that can carry a selection; spanning rows cannot. */
1304
+ _selectableData() {
1305
+ const visible = this._visibleData();
1306
+ return this._rowKind === null ? visible : visible.filter((row) => !this._isSpanningRow(row));
1307
+ }
1308
+
1208
1309
  /** @returns {HTMLElement} */
1209
1310
  _createColumnControls() {
1210
1311
  const list = h('div', { class: 'zx-table__column-list' });
@@ -1213,7 +1314,8 @@ export class Table extends Component {
1213
1314
  class: 'zx-table__column-status', role: 'status', ariaLive: 'polite'
1214
1315
  });
1215
1316
  const details = h('details', { class: 'zx-table__column-controls' },
1216
- h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }), 'Columns'),
1317
+ h('summary', { class: 'zx-btn', dataset: { size: 'sm' } }, icon('fields'),
1318
+ this._message('table.columns', 'Columns', [])),
1217
1319
  list);
1218
1320
  this._renderColumnControls();
1219
1321
  return details;
@@ -1239,20 +1341,23 @@ export class Table extends Component {
1239
1341
  disabled: checked && visible.length === 1,
1240
1342
  dataset: { columnId: column.id, columnAction: 'toggle' }
1241
1343
  }) : null;
1344
+ // The chooser lists every column by name, a blank-headed one included.
1345
+ const name = tableColumnName(column);
1242
1346
  const label = toggle
1243
- ? h('label', { for: controlId }, toggle, h('span', {}, column.label))
1244
- : h('span', { class: 'zx-table__column-label' }, column.label);
1347
+ ? h('label', { for: controlId }, toggle, h('span', {}, name))
1348
+ : h('span', { class: 'zx-table__column-label' }, name);
1245
1349
  const order = this.options.columnReorder ? h('span', {
1246
- class: 'zx-table__column-order', role: 'group', ariaLabel: `Move ${column.label}`
1350
+ class: 'zx-table__column-order', role: 'group',
1351
+ ariaLabel: this._message('table.moveColumn', 'Move %1', [name])
1247
1352
  },
1248
1353
  h('button', {
1249
1354
  class: 'zx-table__column-move', type: 'button', disabled: index === 0,
1250
- ariaLabel: `Move ${column.label} earlier`,
1355
+ ariaLabel: `Move ${name} earlier`,
1251
1356
  dataset: { columnId: column.id, columnAction: 'up' }
1252
1357
  }, icon('chevron-up', { size: 11 })),
1253
1358
  h('button', {
1254
1359
  class: 'zx-table__column-move', type: 'button', disabled: index === this._columns.length - 1,
1255
- ariaLabel: `Move ${column.label} later`,
1360
+ ariaLabel: `Move ${name} later`,
1256
1361
  dataset: { columnId: column.id, columnAction: 'down' }
1257
1362
  }, icon('chevron-down', { size: 11 }))) : null;
1258
1363
  return h('div', { class: 'zx-table__column-option', dataset: { columnId: column.id } }, label, order);
@@ -1494,9 +1599,9 @@ export class Table extends Component {
1494
1599
  * click is usually a bulk action. `showAll()` first if you mean all of them.
1495
1600
  */
1496
1601
  if (this._selectAll.checked) {
1497
- for (const dataRow of this._visibleData()) this._selected.add(this._idFor(dataRow));
1602
+ for (const dataRow of this._selectableData()) this._selected.add(this._idFor(dataRow));
1498
1603
  } else {
1499
- for (const dataRow of this._visibleData()) this._selected.delete(this._idFor(dataRow));
1604
+ for (const dataRow of this._selectableData()) this._selected.delete(this._idFor(dataRow));
1500
1605
  }
1501
1606
  this._selectionAnchorId = null;
1502
1607
  this._syncSelection();
@@ -1507,15 +1612,26 @@ export class Table extends Component {
1507
1612
  }
1508
1613
 
1509
1614
  this._headers = new Map();
1615
+ // What this header was built from, so `_renderBody()` can tell whether it is still the right one.
1616
+ this._headerSpanning = this._spanning;
1510
1617
  for (const column of this._visibleColumns()) {
1511
- const th = h('th', { scope: 'col' });
1618
+ // `data-column` on the header and on every body cell, in every mode, so a read-only table
1619
+ // can be styled per column as readily as an editable one.
1620
+ const th = h('th', { scope: 'col', dataset: { column: column.id } });
1512
1621
  const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1513
1622
  if (align) th.dataset.align = align;
1514
1623
  if (column.headerTitle) th.title = column.headerTitle;
1515
- if (column.sortable) {
1624
+ // A blank header (an icon or actions column) still needs a name, or every cell beneath it
1625
+ // is announced with an empty column header.
1626
+ const name = tableHeaderName(column);
1627
+ if (name !== null) th.setAttribute('aria-label', name);
1628
+ // Withheld, not disabled: a header that invites a sort the component will refuse is worse
1629
+ // than a header that never offered one. The label still reads exactly the same.
1630
+ if (column.sortable && !this._spanning) {
1516
1631
  const button = h('button', {
1517
1632
  class: 'zx-table__sort-button',
1518
- type: 'button'
1633
+ type: 'button',
1634
+ ariaLabel: name ?? undefined
1519
1635
  }, h('span', { class: 'zx-table__header-label' }, column.label));
1520
1636
  this.listen(button, 'click', () => {
1521
1637
  const direction = this._sort?.id === column.id && this._sort.dir === 'asc' ? 'desc' : 'asc';
@@ -1537,6 +1653,18 @@ export class Table extends Component {
1537
1653
  // Every data mutation funnels through here, so this is the single place that guarantees no
1538
1654
  // editor component outlives the rows it was rendered into.
1539
1655
  this._abortEdit({ focus: false });
1656
+ /*
1657
+ * And the single place that notices the data gaining or losing its sections. The header owns
1658
+ * the sort controls, so it has to be rebuilt when the answer flips — otherwise a table that was
1659
+ * given grouped data after construction keeps offering a sort it will now refuse, and one whose
1660
+ * sections were filtered away never gets its sort back.
1661
+ *
1662
+ * Compared against what the *header* was built from, not against `_spanning`: `_sortData()`
1663
+ * runs first on every data mutation and has already brought `_spanning` up to date, so
1664
+ * comparing against it would find no change and leave the stale header in place.
1665
+ */
1666
+ this._spanning = this._hasSpanningRow();
1667
+ if (this._headers && this._spanning !== this._headerSpanning) this._renderHeader();
1540
1668
  const fragment = document.createDocumentFragment();
1541
1669
  this._rowMeta = new WeakMap();
1542
1670
  this._rowElements = new Map();
@@ -1546,11 +1674,7 @@ export class Table extends Component {
1546
1674
  this._hierarchyMeta = new Map(projected.map((entry) => [entry.id, entry]));
1547
1675
  if (this._data.length === 0) {
1548
1676
  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())
1677
+ h('td', { class: 'zx-table__empty', colspan: this._spanColspan() }, this._emptyContent())
1554
1678
  ));
1555
1679
  } else {
1556
1680
  // Indices stay absolute: a row's index is its place in the data, not in what is on screen.
@@ -1599,7 +1723,16 @@ export class Table extends Component {
1599
1723
  if (this._growStep) tr.setAttribute('aria-rowindex', String(tree.visibleIndex + 2));
1600
1724
  const rowClass = typeof this.options.rowClass === 'function' ? this.options.rowClass(row) : '';
1601
1725
  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)));
1726
+ const kind = this._rowKind === null ? null : resolveRowKind(row, this._rowKind);
1727
+ const kindSpec = kind === null ? null : resolveRowKindSpec(this._rowKinds, kind);
1728
+ const spanning = Boolean(kindSpec?.span);
1729
+ if (kind !== null) tr.dataset.rowKind = kind;
1730
+ if (kindSpec?.class) tr.classList.add(...String(kindSpec.class).split(/\s+/).filter(Boolean));
1731
+ // A spanning row is a heading, not a record: `aria-selected="false"` would announce it as one
1732
+ // more selectable row that simply is not selected.
1733
+ if (this.options.selectable !== false && !spanning) {
1734
+ tr.setAttribute('aria-selected', String(this._selected.has(id)));
1735
+ }
1603
1736
  if (this._hierarchy) {
1604
1737
  tr.setAttribute('aria-level', String(tree.depth + 1));
1605
1738
  if (tree.hasChildren) tr.setAttribute('aria-expanded', String(this._expanded.has(id)));
@@ -1609,6 +1742,25 @@ export class Table extends Component {
1609
1742
  elements.push(tr);
1610
1743
  this._rowElements.set(id, elements);
1611
1744
 
1745
+ if (spanning) {
1746
+ tr.dataset.span = 'true';
1747
+ /*
1748
+ * One cell, `<th scope="colgroup">`, covering the reorder and selection columns as well as
1749
+ * the data ones: the row labels the columns of the records beneath it, so it belongs in the
1750
+ * accessibility tree as a heading and not as a value — and a second, narrower cell beside it
1751
+ * would put a blank column back into exactly the layout this removes.
1752
+ */
1753
+ const cell = /** @type {HTMLTableCellElement} */ (h('th', {
1754
+ class: 'zx-table__section-cell', scope: 'colgroup', colspan: this._spanColspan()
1755
+ }));
1756
+ const content = spanRowContent(row, kindSpec, this._visibleColumns(), index);
1757
+ if (content && typeof content === 'object' && typeof content.nodeType === 'number') {
1758
+ cell.append(content);
1759
+ } else if (content != null) cell.append(document.createTextNode(String(content)));
1760
+ tr.append(cell);
1761
+ return tr;
1762
+ }
1763
+
1612
1764
  if (this.options.rowReorder) {
1613
1765
  const handle = h('button', {
1614
1766
  class: 'zx-table__row-handle', type: 'button', draggable: true,
@@ -1637,9 +1789,9 @@ export class Table extends Component {
1637
1789
  // Written unconditionally: `responsive` can be switched on after the rows exist, and a
1638
1790
  // stacked cell has no column header above it to say what its value means.
1639
1791
  cell.dataset.label = column.label;
1792
+ cell.dataset.column = column.id;
1640
1793
  if (column.popin === false) cell.dataset.popin = 'false';
1641
1794
  if (this._editMode !== false) {
1642
- cell.dataset.column = column.id;
1643
1795
  if (resolveEditable(column, row) !== false) {
1644
1796
  cell.dataset.editable = 'true';
1645
1797
  cell.tabIndex = -1;
@@ -1718,7 +1870,9 @@ export class Table extends Component {
1718
1870
  return;
1719
1871
  }
1720
1872
 
1721
- if (isInteractiveCellContent(target)) return;
1873
+ // Only an editable table holds a click on a control inside a cell back from the row: there the
1874
+ // click could otherwise open an editor. A read-only table activates the row as it always has.
1875
+ if (this._editMode !== false && isInteractiveCellContent(target)) return;
1722
1876
 
1723
1877
  if (this._editMode !== false && this._editTrigger === 'single') {
1724
1878
  const cell = target?.closest('td[data-editable="true"]');
@@ -1729,7 +1883,10 @@ export class Table extends Component {
1729
1883
  }
1730
1884
 
1731
1885
  this.emit('rowclick', { ...meta, event });
1732
- if (this.options.selectable === 'single' && !this._selected.has(meta.id)) {
1886
+ // The click still reaches the host — collapsing a section is a reasonable thing to hang off it —
1887
+ // but it does not select the heading, which carries no checkbox in `multi` either.
1888
+ if (this.options.selectable === 'single' && !this._selected.has(meta.id)
1889
+ && !this._isSpanningRow(meta.row)) {
1733
1890
  this._selected = new Set([meta.id]);
1734
1891
  this._selectionAnchorId = meta.id;
1735
1892
  this._syncSelection();
@@ -1746,7 +1903,7 @@ export class Table extends Component {
1746
1903
  if (!rowElement || !this._tbody.contains(rowElement)) return;
1747
1904
  const meta = this._rowMeta.get(rowElement);
1748
1905
  if (!meta) return;
1749
- if (isInteractiveCellContent(target)) return;
1906
+ if (this._editMode !== false && isInteractiveCellContent(target)) return;
1750
1907
  if (this._editMode !== false && this._editTrigger === 'double') {
1751
1908
  const cell = target?.closest('td[data-editable="true"]');
1752
1909
  if (cell && this._tbody.contains(cell)) {
@@ -1892,7 +2049,7 @@ export class Table extends Component {
1892
2049
  ...props,
1893
2050
  class: textarea ? 'zx-table__editor-input zx-table__editor-textarea' : 'zx-table__editor-input',
1894
2051
  ...(textarea ? {} : { type: 'text' }),
1895
- ariaLabel: props.ariaLabel ?? `${column.label} row ${index + 1}`,
2052
+ ariaLabel: props.ariaLabel ?? `${tableColumnName(column)} row ${index + 1}`,
1896
2053
  value: formatCellValue(column, value, row)
1897
2054
  }));
1898
2055
  host.append(input);
@@ -1911,8 +2068,10 @@ export class Table extends Component {
1911
2068
  const control = entry?.control;
1912
2069
  if (!control || typeof control.focus !== 'function') return;
1913
2070
  control.focus();
1914
- // Opening a text editor selects its content, so typing replaces the old value.
1915
- if (entry.type === 'text' && typeof control.select === 'function') control.select();
2071
+ // Opening a text, number, or date editor selects its content, so typing replaces the old value
2072
+ // ("6" over "1.00" gives 6, not 1.006). Selected after focus: NumberField rewrites its text on
2073
+ // focus, from the grouped display to a plain re-parseable number.
2074
+ if (editorSelectsOnEntry(entry.type) && typeof control.select === 'function') control.select();
1916
2075
  }
1917
2076
 
1918
2077
  /**
@@ -2093,6 +2252,8 @@ export class Table extends Component {
2093
2252
  if (row < 0 || row >= visible.length) return null;
2094
2253
  const candidateRow = visible[row];
2095
2254
  const candidateColumn = columns[column];
2255
+ // Tab walks past a section heading rather than stopping on a row that has no cells to edit.
2256
+ if (this._isSpanningRow(candidateRow)) continue;
2096
2257
  if (resolveEditable(candidateColumn, candidateRow) !== false) {
2097
2258
  return {
2098
2259
  id: this._idFor(candidateRow),
@@ -2172,6 +2333,9 @@ export class Table extends Component {
2172
2333
  const start = Math.min(anchorIndex, currentIndex);
2173
2334
  const end = Math.max(anchorIndex, currentIndex);
2174
2335
  for (let index = start; index <= end; index += 1) {
2336
+ // Indices stay against the rendered rows so `visibleIndex` still lines up; a section caught
2337
+ // inside the range is stepped over rather than selected.
2338
+ if (this._isSpanningRow(visible[index])) continue;
2175
2339
  const id = this._idFor(visible[index]);
2176
2340
  if (checked) this._selected.add(id);
2177
2341
  else this._selected.delete(id);
@@ -2191,6 +2355,9 @@ export class Table extends Component {
2191
2355
  for (const [id, rows] of this._rowElements) {
2192
2356
  const selected = this._selected.has(id);
2193
2357
  for (const row of rows) {
2358
+ // A spanning row never claimed `aria-selected`; writing it here would tell a screen reader
2359
+ // the heading is a selectable record that happens to be unselected.
2360
+ if (row.hasAttribute('data-span')) continue;
2194
2361
  row.setAttribute('aria-selected', String(selected));
2195
2362
  const checkbox = row.querySelector('.zx-table__row-checkbox');
2196
2363
  if (checkbox) checkbox.checked = selected;
@@ -2202,8 +2369,9 @@ export class Table extends Component {
2202
2369
  /** @returns {void} */
2203
2370
  _syncSelectAll() {
2204
2371
  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();
2372
+ // Measured against what is rendered, so the box reflects the rows the reader can actually see —
2373
+ // and only the ones that can be ticked, so a section heading never holds the box indeterminate.
2374
+ const visible = this._selectableData();
2207
2375
  const selectedRows = visible.reduce(
2208
2376
  (count, row) => count + Number(this._selected.has(this._idFor(row))),
2209
2377
  0
@@ -2217,14 +2385,20 @@ export class Table extends Component {
2217
2385
  _syncSortHeader() {
2218
2386
  if (!this._headers) return;
2219
2387
  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');
2388
+ // A withheld control must not leave `aria-sort` behind: with the sort inert the attribute
2389
+ // would announce an order the rows are not actually in.
2390
+ if (!this._spanning && this._sort?.id === id) {
2391
+ header.setAttribute('aria-sort', this._sort.dir === 'asc' ? 'ascending' : 'descending');
2392
+ } else header.removeAttribute('aria-sort');
2222
2393
  }
2223
2394
  }
2224
2395
 
2225
2396
  /** @returns {void} */
2226
2397
  _sortData() {
2227
- if (!this._sort) return;
2398
+ // Recomputed here and not only in `_renderBody()`: every data mutation sorts before it renders,
2399
+ // so data that has just gained a section must be seen as grouped before the sort would run.
2400
+ this._spanning = this._hasSpanningRow();
2401
+ if (!this._sort || this._spanning) return;
2228
2402
  const column = this._columns.find((candidate) => candidate.id === this._sort.id);
2229
2403
  if (!column) return;
2230
2404
  const getValue = typeof column.sortValue === 'function' ? column.sortValue : (row) => row?.[column.id];
@@ -2263,9 +2437,18 @@ export class Table extends Component {
2263
2437
  for (const id of this._expanded) if (!valid.has(id)) this._expanded.delete(id);
2264
2438
  }
2265
2439
 
2440
+ /** @returns {Set<unknown>} Ids of every row in the data that may hold a selection. */
2441
+ _selectableIds() {
2442
+ const ids = new Set();
2443
+ for (const row of this._data) {
2444
+ if (!this._isSpanningRow(row)) ids.add(this._idFor(row));
2445
+ }
2446
+ return ids;
2447
+ }
2448
+
2266
2449
  /** @returns {boolean} Whether selection changed. */
2267
2450
  _pruneSelection() {
2268
- const valid = new Set(this._data.map((row) => this._idFor(row)));
2451
+ const valid = this._selectableIds();
2269
2452
  const previousSize = this._selected.size;
2270
2453
  for (const id of this._selected) if (!valid.has(id)) this._selected.delete(id);
2271
2454
  if (!valid.has(this._selectionAnchorId)) this._selectionAnchorId = null;
@@ -2337,6 +2520,104 @@ export class Table extends Component {
2337
2520
  * @type {CustomEvent<TableEditInvalidDetail>}
2338
2521
  */
2339
2522
 
2523
+ /**
2524
+ * Resolves a row's kind through the `rowKind` accessor and coerces it to the string `rowKinds` is
2525
+ * keyed by.
2526
+ *
2527
+ * Only `null` and `undefined` mean "no kind". `0` and `''` are kinds like any other: a ZeyOS
2528
+ * transaction line uses `0` for a position and `1` for a section, so a falsy test here would render
2529
+ * every position as though the table had never been told about kinds at all.
2530
+ * @param {TableRow} row Row to classify.
2531
+ * @param {string|((row: TableRow) => unknown)|null|undefined} rowKind Field name or accessor.
2532
+ * @returns {string|null} The kind as a string, or null when there is none.
2533
+ */
2534
+ export function resolveRowKind(row, rowKind) {
2535
+ if (rowKind == null || rowKind === '') return null;
2536
+ const raw = typeof rowKind === 'function' ? rowKind(row) : row?.[rowKind];
2537
+ return raw == null ? null : String(raw);
2538
+ }
2539
+
2540
+ /**
2541
+ * Looks one resolved kind up in the `rowKinds` map.
2542
+ *
2543
+ * Own properties only: a kind read out of server data could just as easily be `toString` or
2544
+ * `constructor`, and inherited members of `Object.prototype` are not configuration.
2545
+ * @param {Record<string, TableRowKind>|null|undefined} rowKinds Kind map.
2546
+ * @param {string|null} kind Resolved kind.
2547
+ * @returns {TableRowKind|null} Matching entry, or null.
2548
+ */
2549
+ export function resolveRowKindSpec(rowKinds, kind) {
2550
+ if (kind === null || rowKinds == null || typeof rowKinds !== 'object') return null;
2551
+ if (!Object.prototype.hasOwnProperty.call(rowKinds, kind)) return null;
2552
+ const spec = rowKinds[kind];
2553
+ return spec && typeof spec === 'object' ? spec : null;
2554
+ }
2555
+
2556
+ /**
2557
+ * Whether a row draws as one spanning cell under the given configuration.
2558
+ * @param {TableRow} row Row to classify.
2559
+ * @param {string|((row: TableRow) => unknown)|null|undefined} rowKind Field name or accessor.
2560
+ * @param {Record<string, TableRowKind>|null|undefined} rowKinds Kind map.
2561
+ * @returns {boolean}
2562
+ */
2563
+ export function isSpanningRow(row, rowKind, rowKinds) {
2564
+ return Boolean(resolveRowKindSpec(rowKinds, resolveRowKind(row, rowKind))?.span);
2565
+ }
2566
+
2567
+ /**
2568
+ * Whether a data set holds a row that spans — the question that decides whether `Table` offers its
2569
+ * sort controls at all.
2570
+ *
2571
+ * Asked of the whole data rather than of the rendered prefix: with `growing` on, a sort control
2572
+ * that disappeared the moment the reader pressed "show 20 more" would be worse than one that was
2573
+ * never there. Asked again on every data change, so a filter that removes the last section gives
2574
+ * the sort back.
2575
+ * @param {TableRow[]} rows Rows to scan.
2576
+ * @param {string|((row: TableRow) => unknown)|null|undefined} rowKind Field name or accessor.
2577
+ * @param {Record<string, TableRowKind>|null|undefined} rowKinds Kind map.
2578
+ * @returns {boolean}
2579
+ */
2580
+ export function hasSpanningRow(rows, rowKind, rowKinds) {
2581
+ if (!Array.isArray(rows) || rowKind == null || rowKinds == null) return false;
2582
+ return rows.some((row) => isSpanningRow(row, rowKind, rowKinds));
2583
+ }
2584
+
2585
+ /**
2586
+ * How many cells one table row occupies.
2587
+ *
2588
+ * `selectable: 'multi'` and `rowReorder` each render a `<col>` and a leading cell of their own, and
2589
+ * a spanning cell that ignored them would leave the row one or two columns short — the table would
2590
+ * still lay out, silently and wrongly. `selectable: 'single'` adds no column: the whole row is the
2591
+ * control there.
2592
+ * @param {number} columnCount Number of visible data columns.
2593
+ * @param {{selectable?: TableSelectionMode, rowReorder?: boolean}} [structure={}] Structural columns.
2594
+ * @returns {number} Colspan covering every rendered cell, never below 1.
2595
+ */
2596
+ export function tableSpanColspan(columnCount, structure = {}) {
2597
+ const columns = Number.isFinite(Number(columnCount)) ? Math.max(0, Math.trunc(Number(columnCount))) : 0;
2598
+ const extras = (structure.selectable === 'multi' ? 1 : 0) + (structure.rowReorder ? 1 : 0);
2599
+ return Math.max(1, columns + extras);
2600
+ }
2601
+
2602
+ /**
2603
+ * Content for a spanning row's single cell.
2604
+ *
2605
+ * Without a `render` the cell shows the row's raw value for the first visible column — the same
2606
+ * thing a host writes by hand today. Deliberately raw rather than routed through the column's own
2607
+ * renderer or display type: that renderer was written for a record, and running it against a
2608
+ * heading that carries no quantities or amounts is how a section row ends up showing `0,00 €`.
2609
+ * @param {TableRow} row Row being rendered.
2610
+ * @param {TableRowKind|null|undefined} spec Resolved kind presentation.
2611
+ * @param {TableColumn[]} visibleColumns Columns currently rendered, in display order.
2612
+ * @param {number} [index=0] Flat row index.
2613
+ * @returns {Node|string|number|null|undefined} Cell content.
2614
+ */
2615
+ export function spanRowContent(row, spec, visibleColumns, index = 0) {
2616
+ if (typeof spec?.render === 'function') return spec.render(row, index);
2617
+ const first = visibleColumns?.[0];
2618
+ return first ? row?.[first.id] : null;
2619
+ }
2620
+
2340
2621
  /**
2341
2622
  * Applies a column's built-in display type. A custom renderer always wins and may still return a
2342
2623
  * Node; all built-in results are strings so `_renderCellContent` can insert them as text.
@@ -2724,11 +3005,42 @@ function normalizeHierarchy(option, columns) {
2724
3005
  return { ...option, parentId, column };
2725
3006
  }
2726
3007
 
3008
+ /**
3009
+ * Normalizes the `rowKind` accessor. Anything that is not a non-empty field name or a callback is
3010
+ * `null`, which is the switch that keeps every existing table on exactly its old rendering path.
3011
+ * @param {unknown} option Configured accessor.
3012
+ * @returns {string|((row: TableRow) => unknown)|null}
3013
+ */
3014
+ function normalizeRowKind(option) {
3015
+ if (typeof option === 'function') return /** @type {(row: TableRow) => unknown} */ (option);
3016
+ return typeof option === 'string' && option !== '' ? option : null;
3017
+ }
3018
+
3019
+ /**
3020
+ * Normalizes the `rowKinds` map. An array, a function or a primitive is not a keyed map and is
3021
+ * discarded rather than half-read.
3022
+ * @param {unknown} option Configured kind map.
3023
+ * @returns {Record<string, TableRowKind>|null}
3024
+ */
3025
+ function normalizeRowKinds(option) {
3026
+ if (!option || typeof option !== 'object' || Array.isArray(option)) return null;
3027
+ return /** @type {Record<string, TableRowKind>} */ (option);
3028
+ }
3029
+
2727
3030
  /** @param {unknown} option @returns {Set<unknown>} */
2728
3031
  function initialExpanded(option) {
2729
3032
  return new Set(Array.isArray(option) ? option : []);
2730
3033
  }
2731
3034
 
3035
+ /**
3036
+ * Resolves the `variant` option to one the stylesheet knows; anything else is the default frame.
3037
+ * @param {unknown} value Requested variant.
3038
+ * @returns {TableVariant}
3039
+ */
3040
+ export function normalizeTableVariant(value) {
3041
+ return value === 'plain' ? 'plain' : 'default';
3042
+ }
3043
+
2732
3044
  /** @param {unknown} mode @returns {TableEditMode} */
2733
3045
  function normalizeEditMode(mode) {
2734
3046
  if (mode === 'cell' || mode === 'row') return mode;
@@ -2780,6 +3092,47 @@ export function normalizeTableColumns(columns) {
2780
3092
  });
2781
3093
  }
2782
3094
 
3095
+ /**
3096
+ * The name a column goes by wherever it is named rather than shown: its `ariaLabel`, else its
3097
+ * visible label, else its id. The column chooser lists columns by it and cell editors are labelled
3098
+ * with it, so a column with a blank header still has a name in both.
3099
+ * @param {Partial<TableColumn>|null|undefined} column Column descriptor.
3100
+ * @returns {string} Non-empty name, unless the column has no id either.
3101
+ */
3102
+ export function tableColumnName(column) {
3103
+ for (const candidate of [column?.ariaLabel, column?.label]) {
3104
+ const text = candidate == null ? '' : String(candidate).trim();
3105
+ if (text) return text;
3106
+ }
3107
+ return String(column?.id ?? '');
3108
+ }
3109
+
3110
+ /**
3111
+ * The accessible name a column header needs on top of its content, or null when its visible label
3112
+ * already names it. That is the `ariaLabel` when one is given, and the column id when the label is
3113
+ * blank — an icon or actions column — so no header is announced as an empty column.
3114
+ * @param {Partial<TableColumn>|null|undefined} column Column descriptor.
3115
+ * @returns {string|null} Name to set as `aria-label`, or null.
3116
+ */
3117
+ export function tableHeaderName(column) {
3118
+ const label = column?.label == null ? '' : String(column.label).trim();
3119
+ const explicit = column?.ariaLabel == null ? '' : String(column.ariaLabel).trim();
3120
+ if (explicit) return explicit;
3121
+ return label ? null : tableColumnName(column) || null;
3122
+ }
3123
+
3124
+ /**
3125
+ * Whether opening an editor of this type selects its content, so the first keystroke replaces the
3126
+ * value instead of being appended to it. True for the single-line text, number, and date editors;
3127
+ * a textarea keeps its caret, and a select, checkbox, or custom editor keeps its own focus
3128
+ * behaviour.
3129
+ * @param {TableEditorType|string} type Resolved editor type.
3130
+ * @returns {boolean}
3131
+ */
3132
+ export function editorSelectsOnEntry(type) {
3133
+ return type === 'text' || type === 'number' || type === 'date';
3134
+ }
3135
+
2783
3136
  /**
2784
3137
  * Reconciles saved or partial column IDs with every current column.
2785
3138
  * @param {TableColumn[]} columns Current columns.