@zeyos/zx 4.4.0 → 5.2.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 (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  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 +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. 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 */
@@ -59,7 +60,11 @@ import { sortRows } from './sort.js';
59
60
  /**
60
61
  * @typedef {Object} TableColumn
61
62
  * @property {string} id Row property and stable column identifier.
62
- * @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.
63
68
  * @property {boolean} [sortable=false] Whether the header changes the active sort.
64
69
  * @property {string} [width='auto'] CSS width, `auto`, or an `fr` proportion. `fr` values are
65
70
  * converted into proportional percentages on `<col>` elements and enable `table-layout: fixed`;
@@ -128,6 +133,9 @@ import { sortRows } from './sort.js';
128
133
  * `setSort()` is inert: sorting a grouped table scatters the positions out of their sections, and
129
134
  * doing that silently is worse than not offering it. `sortMode: 'server'` still owns its own order.
130
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.
131
139
  * @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
132
140
  * opens every editable cell of the row together and commits them as a unit. Editing is completely
133
141
  * inert while this is `false`.
@@ -139,6 +147,15 @@ import { sortRows } from './sort.js';
139
147
  * @property {string[]} [hiddenColumns=[]] Initially hidden column IDs; at least one stays visible.
140
148
  * @property {false|TableHierarchyOptions} [hierarchy=false] Flat parent references projected as an
141
149
  * expandable treegrid. Row data and mutation APIs remain flat.
150
+ * @property {boolean} [interactive=false] Makes the table an APG grid — row focus, Enter firing
151
+ * `rowclick` — even without selection, editing, or hierarchy, which already imply it. Attaching a
152
+ * `rowclick` or `rowdblclick` listener through `on()` or an `on…` option sets it too; pass it when
153
+ * the host listens for the bubbling `zx-rowclick` DOM event instead.
154
+ * @property {string|null} [label=null] Accessible name of the table. A grid must have one; null
155
+ * resolves the `table.label` message (English "Table") for a grid and leaves a plain table unnamed,
156
+ * so name it after what it lists.
157
+ * @property {string|null} [labelledBy=null] Id of an element that names the grid — the heading
158
+ * above it, say. Wins over `label`.
142
159
  * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowclick]
143
160
  * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowdblclick]
144
161
  * @property {(event: CustomEvent<TableSortDetail>) => void} [onsort]
@@ -251,6 +268,20 @@ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1
251
268
  /**
252
269
  * Semantic, sortable data table with optional single or checkbox-based multi-selection and
253
270
  * opt-in inline cell or row editing.
271
+ *
272
+ * An interactive table — one that is `selectable`, has `editMode` or `hierarchy`, sets
273
+ * `interactive: true`, or has a `rowclick`/`rowdblclick` listener attached through `on()` or an
274
+ * `on…` option — follows the WAI-ARIA APG Grid pattern (`role="grid"`), or the Treegrid pattern
275
+ * (`role="treegrid"`) with `hierarchy`. Any other table keeps plain `<table>` semantics and no
276
+ * keyboard model of its own. In a grid the rows are one Tab stop with a roving `tabindex`. Rows
277
+ * take focus by default; a table with `editMode` moves focus between cells instead, which the root
278
+ * reflects as `data-focus="row"|"cell"`. ArrowUp/ArrowDown move between rows, ArrowLeft/ArrowRight
279
+ * between cells (cell focus) or expand, collapse, and walk the tree (treegrid), Home/End and
280
+ * Ctrl+Home/End jump to the ends, PageUp/PageDown move by a visible page (10 rows when the table
281
+ * does not scroll). Enter activates the row exactly as a click does (`rowclick`), Space toggles the
282
+ * row's selection when `selectable` (and activates otherwise), Shift+Arrow extends a `multi`
283
+ * selection, and Ctrl/Cmd+A selects every rendered row. The focused row survives `setData`, sorting,
284
+ * expansion, and `updateRow`. Column headers keep their sort buttons in the Tab order.
254
285
  * @fires Table#rowclick
255
286
  * @fires Table#rowdblclick
256
287
  * @fires Table#sort
@@ -288,9 +319,13 @@ export class Table extends Component {
288
319
  rowKind: null,
289
320
  rowKinds: null,
290
321
  zebra: true,
322
+ variant: 'default',
291
323
  editMode: false,
292
324
  editTrigger: 'double',
293
325
  hierarchy: false,
326
+ interactive: false,
327
+ label: null,
328
+ labelledBy: null,
294
329
  rowReorder: false,
295
330
  columnVisibility: false,
296
331
  columnReorder: false,
@@ -327,6 +362,7 @@ export class Table extends Component {
327
362
  this._selectionAnchorId = null;
328
363
  this._sort = normalizeSort(this.options.sort, this._columns);
329
364
  this._rowMeta = new WeakMap();
365
+ this._rowNames = new WeakMap();
330
366
  this._rowElements = new Map();
331
367
  this._restored = false;
332
368
  // Editing state. `_edit` is null unless a cell or row is currently open for editing; every
@@ -340,9 +376,17 @@ export class Table extends Component {
340
376
  this._growStep = growStep(this.options.growing);
341
377
  this._rendered = this._growStep ?? Infinity;
342
378
  this._more = null;
343
- this._rovingKey = null;
344
- this._rovingCell = null;
345
- this._rovingMatched = false;
379
+ // Keyboard focus. `_current` is the row (and, with cell focus, the column) holding the grid's
380
+ // single Tab stop; it is kept by id so the stop survives every re-render of the body.
381
+ this._focusMode = this._editMode === false ? 'row' : 'cell';
382
+ // `_clickListened` is set by `addEventListener()`, which the base constructor runs for `on…`
383
+ // options before this method; it is deliberately not reset here.
384
+ this._interactive = Boolean(this.options.interactive || this.options.selectable
385
+ || this._editMode !== false || normalizeHierarchy(this.options.hierarchy, this._columns)
386
+ || this._clickListened);
387
+ this._current = null;
388
+ this._rovingEl = null;
389
+ this._extendBase = null;
346
390
  this._hierarchy = normalizeHierarchy(this.options.hierarchy, this._columns);
347
391
  this._hiddenColumns = new Set((Array.isArray(this.options.hiddenColumns) ? this.options.hiddenColumns : [])
348
392
  .map(String).filter((id) => this._columns.some((column) => column.id === id)));
@@ -361,8 +405,10 @@ export class Table extends Component {
361
405
  }
362
406
  this._reconcileExpanded();
363
407
 
408
+ if (normalizeTableVariant(this.options.variant) === 'plain') root.dataset.variant = 'plain';
409
+
364
410
  this._table = h('table', { class: 'zx-table__table' });
365
- if (this._hierarchy) this._table.setAttribute('role', 'treegrid');
411
+ this._syncGridRole();
366
412
  this._colgroup = h('colgroup', { class: 'zx-table__columns' });
367
413
  this._thead = h('thead', { class: 'zx-table__head' });
368
414
  this._tbody = h('tbody', { class: 'zx-table__body' });
@@ -423,9 +469,12 @@ export class Table extends Component {
423
469
  }
424
470
  if (this._editMode !== false) {
425
471
  this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
426
- this.listen(this._tbody, 'focusin', (event) => this._handleEditFocusIn(event));
427
472
  this.listen(document, 'pointerdown', (event) => this._handleDocumentPointerDown(event), { capture: true });
428
473
  }
474
+ // Registered after the editing and reorder handlers, which claim their keys first and mark them
475
+ // with preventDefault().
476
+ this.listen(this._tbody, 'keydown', (event) => this._handleGridKeydown(/** @type {KeyboardEvent} */ (event)));
477
+ this.listen(this._tbody, 'focusin', (event) => this._handleFocusIn(event));
429
478
  return root;
430
479
  }
431
480
 
@@ -519,6 +568,7 @@ export class Table extends Component {
519
568
  this._reconcileExpanded();
520
569
  const selectionChanged = this._selected.delete(id);
521
570
  if (Object.is(this._selectionAnchorId, id)) this._selectionAnchorId = null;
571
+ this._extendBase = null;
522
572
  this._renderBody();
523
573
  if (selectionChanged) this._emitSelectionChange();
524
574
  this._emitDataChange();
@@ -638,7 +688,7 @@ export class Table extends Component {
638
688
  const column = this._columns.find((candidate) => candidate.id === String(id));
639
689
  const index = this.getColumnOrder().indexOf(String(id));
640
690
  if (column && index >= 0) {
641
- this._columnStatus.textContent = `Moved ${column.label} to position ${index + 1} of ${this._columns.length}.`;
691
+ this._columnStatus.textContent = `Moved ${tableColumnName(column)} to position ${index + 1} of ${this._columns.length}.`;
642
692
  }
643
693
  }
644
694
  return this;
@@ -732,6 +782,7 @@ export class Table extends Component {
732
782
  this._expanded.clear();
733
783
  this._selected.clear();
734
784
  this._selectionAnchorId = null;
785
+ this._extendBase = null;
735
786
  this._renderBody();
736
787
  if (selectionChanged) this._emitSelectionChange();
737
788
  this._emitDataChange();
@@ -826,6 +877,7 @@ export class Table extends Component {
826
877
  if (this.options.selectable === 'single' && next.size > 0) break;
827
878
  }
828
879
  }
880
+ this._extendBase = null;
829
881
  if (sameSet(this._selected, next)) return this;
830
882
  this._selected = next;
831
883
  this._syncSelection();
@@ -842,6 +894,7 @@ export class Table extends Component {
842
894
  if (this._selected.size === 0) return this;
843
895
  this._selected.clear();
844
896
  this._selectionAnchorId = null;
897
+ this._extendBase = null;
845
898
  this._syncSelection();
846
899
  this._emitSelectionChange();
847
900
  return this;
@@ -1205,25 +1258,67 @@ export class Table extends Component {
1205
1258
  this.emit('stackedchange', { stacked });
1206
1259
  }
1207
1260
 
1261
+ /**
1262
+ * Registers a listener, and turns the table into a grid when the listener is for a row click:
1263
+ * a host that reacts to the row needs the row reachable by keyboard.
1264
+ * @param {string} type Event name.
1265
+ * @param {EventListenerOrEventListenerObject|null} listener Listener.
1266
+ * @param {boolean|AddEventListenerOptions} [options] Listener options.
1267
+ * @returns {void}
1268
+ */
1269
+ addEventListener(type, listener, options) {
1270
+ super.addEventListener(type, listener, options);
1271
+ if (!listener || type !== 'rowclick' && type !== 'rowdblclick') return;
1272
+ this._clickListened = true;
1273
+ if (this._table && !this._interactive) {
1274
+ this._interactive = true;
1275
+ this._syncGridRole();
1276
+ this._renderBody();
1277
+ }
1278
+ }
1279
+
1280
+ /** @returns {'grid'|'treegrid'|null} Role of the `<table>`, or null for plain table semantics. */
1281
+ _gridRole() {
1282
+ if (!this._interactive) return null;
1283
+ return this._hierarchy ? 'treegrid' : 'grid';
1284
+ }
1285
+
1286
+ /** Writes the grid role, name, and focus mode — or their absence — onto the table. @returns {void} */
1287
+ _syncGridRole() {
1288
+ const table = this._table;
1289
+ const role = this._gridRole();
1290
+ if (role) table.setAttribute('role', role);
1291
+ else table.removeAttribute('role');
1292
+ if (this.options.labelledBy) {
1293
+ table.setAttribute('aria-labelledby', String(this.options.labelledBy));
1294
+ } else if (this.options.label != null || role) {
1295
+ table.setAttribute('aria-label', this.options.label ?? this._message('table.label', 'Table', []));
1296
+ }
1297
+ if (role && this.options.selectable === 'multi') table.setAttribute('aria-multiselectable', 'true');
1298
+ if (role) this.el.dataset.focus = this._focusMode;
1299
+ else delete this.el.dataset.focus;
1300
+ }
1301
+
1208
1302
  /**
1209
1303
  * States the table roles explicitly while stacked, and removes them again on the way out.
1210
1304
  *
1211
1305
  * Stacking works by changing `display` on the table elements, and a table whose display is not
1212
1306
  * `table` loses its implicit ARIA roles — leaving a screen reader with a pile of generic blocks
1213
- * where a table used to be. The roles are only asserted while the layout has taken them away.
1307
+ * where a table used to be. The roles are only asserted while the layout has taken them away;
1308
+ * a grid keeps its explicit grid or treegrid role in either mode.
1214
1309
  * @param {boolean} stacked Whether the roles should be present.
1215
1310
  * @returns {void}
1216
1311
  */
1217
1312
  _applyStackedRoles(stacked) {
1218
- /** @type {Array<[Element|null, string]>} */
1219
- const parts = [[this._table, this._hierarchy ? 'treegrid' : 'table'], [this._thead, 'rowgroup'], [this._tbody, 'rowgroup']];
1220
- for (const [element, role] of parts) {
1221
- if (!element) continue;
1222
- if (stacked) element.setAttribute('role', role);
1223
- else if (element === this._table && this._hierarchy) element.setAttribute('role', 'treegrid');
1313
+ const grid = this._gridRole();
1314
+ if (grid) this._table.setAttribute('role', grid);
1315
+ else if (stacked) this._table.setAttribute('role', 'table');
1316
+ else this._table.removeAttribute('role');
1317
+ for (const element of [this._thead, this._tbody]) {
1318
+ if (stacked) element.setAttribute('role', 'rowgroup');
1224
1319
  else element.removeAttribute('role');
1225
1320
  }
1226
- for (const [selector, role] of [['tr', 'row'], ['td', 'cell'], ['th', 'columnheader']]) {
1321
+ for (const [selector, role] of [['tr', 'row'], ['td', grid ? 'gridcell' : 'cell'], ['th', 'columnheader']]) {
1227
1322
  for (const element of this._table.querySelectorAll(selector)) {
1228
1323
  if (stacked) element.setAttribute('role', role);
1229
1324
  else element.removeAttribute('role');
@@ -1303,7 +1398,7 @@ export class Table extends Component {
1303
1398
  class: 'zx-table__column-status', role: 'status', ariaLive: 'polite'
1304
1399
  });
1305
1400
  const details = h('details', { class: 'zx-table__column-controls' },
1306
- h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }),
1401
+ h('summary', { class: 'zx-btn', dataset: { size: 'sm' } }, icon('fields'),
1307
1402
  this._message('table.columns', 'Columns', [])),
1308
1403
  list);
1309
1404
  this._renderColumnControls();
@@ -1330,21 +1425,23 @@ export class Table extends Component {
1330
1425
  disabled: checked && visible.length === 1,
1331
1426
  dataset: { columnId: column.id, columnAction: 'toggle' }
1332
1427
  }) : null;
1428
+ // The chooser lists every column by name, a blank-headed one included.
1429
+ const name = tableColumnName(column);
1333
1430
  const label = toggle
1334
- ? h('label', { for: controlId }, toggle, h('span', {}, column.label))
1335
- : h('span', { class: 'zx-table__column-label' }, column.label);
1431
+ ? h('label', { for: controlId }, toggle, h('span', {}, name))
1432
+ : h('span', { class: 'zx-table__column-label' }, name);
1336
1433
  const order = this.options.columnReorder ? h('span', {
1337
1434
  class: 'zx-table__column-order', role: 'group',
1338
- ariaLabel: this._message('table.moveColumn', 'Move %1', [column.label])
1435
+ ariaLabel: this._message('table.moveColumn', 'Move %1', [name])
1339
1436
  },
1340
1437
  h('button', {
1341
1438
  class: 'zx-table__column-move', type: 'button', disabled: index === 0,
1342
- ariaLabel: `Move ${column.label} earlier`,
1439
+ ariaLabel: `Move ${name} earlier`,
1343
1440
  dataset: { columnId: column.id, columnAction: 'up' }
1344
1441
  }, icon('chevron-up', { size: 11 })),
1345
1442
  h('button', {
1346
1443
  class: 'zx-table__column-move', type: 'button', disabled: index === this._columns.length - 1,
1347
- ariaLabel: `Move ${column.label} later`,
1444
+ ariaLabel: `Move ${name} later`,
1348
1445
  dataset: { columnId: column.id, columnAction: 'down' }
1349
1446
  }, icon('chevron-down', { size: 11 }))) : null;
1350
1447
  return h('div', { class: 'zx-table__column-option', dataset: { columnId: column.id } }, label, order);
@@ -1572,7 +1669,7 @@ export class Table extends Component {
1572
1669
  this._selectAll = h('input', {
1573
1670
  class: 'zx-table__checkbox zx-table__select-all',
1574
1671
  type: 'checkbox',
1575
- ariaLabel: 'Select all rows'
1672
+ ariaLabel: this._message('table.selectAll', 'Select all rows', [])
1576
1673
  });
1577
1674
  const selectionHeader = h('th', {
1578
1675
  class: 'zx-table__selection-header',
@@ -1591,6 +1688,7 @@ export class Table extends Component {
1591
1688
  for (const dataRow of this._selectableData()) this._selected.delete(this._idFor(dataRow));
1592
1689
  }
1593
1690
  this._selectionAnchorId = null;
1691
+ this._extendBase = null;
1594
1692
  this._syncSelection();
1595
1693
  this._emitSelectionChange();
1596
1694
  });
@@ -1602,16 +1700,23 @@ export class Table extends Component {
1602
1700
  // What this header was built from, so `_renderBody()` can tell whether it is still the right one.
1603
1701
  this._headerSpanning = this._spanning;
1604
1702
  for (const column of this._visibleColumns()) {
1605
- const th = h('th', { scope: 'col' });
1703
+ // `data-column` on the header and on every body cell, in every mode, so a read-only table
1704
+ // can be styled per column as readily as an editable one.
1705
+ const th = h('th', { scope: 'col', dataset: { column: column.id } });
1606
1706
  const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1607
1707
  if (align) th.dataset.align = align;
1608
1708
  if (column.headerTitle) th.title = column.headerTitle;
1709
+ // A blank header (an icon or actions column) still needs a name, or every cell beneath it
1710
+ // is announced with an empty column header.
1711
+ const name = tableHeaderName(column);
1712
+ if (name !== null) th.setAttribute('aria-label', name);
1609
1713
  // Withheld, not disabled: a header that invites a sort the component will refuse is worse
1610
1714
  // than a header that never offered one. The label still reads exactly the same.
1611
1715
  if (column.sortable && !this._spanning) {
1612
1716
  const button = h('button', {
1613
1717
  class: 'zx-table__sort-button',
1614
- type: 'button'
1718
+ type: 'button',
1719
+ ariaLabel: name ?? undefined
1615
1720
  }, h('span', { class: 'zx-table__header-label' }, column.label));
1616
1721
  this.listen(button, 'click', () => {
1617
1722
  const direction = this._sort?.id === column.id && this._sort.dir === 'asc' ? 'desc' : 'asc';
@@ -1630,9 +1735,16 @@ export class Table extends Component {
1630
1735
 
1631
1736
  /** @returns {void} @fires Table#editcancel */
1632
1737
  _renderBody() {
1738
+ // Read before anything is torn down: whether focus is in the body, and on which part of a row,
1739
+ // decides where it lands again once the rows are rebuilt.
1740
+ const focus = this._captureFocus();
1633
1741
  // Every data mutation funnels through here, so this is the single place that guarantees no
1634
1742
  // editor component outlives the rows it was rendered into.
1635
1743
  this._abortEdit({ focus: false });
1744
+ // A range anchored on the old order would select the wrong rows in the new one: Shift+Arrow and
1745
+ // Shift+click start over after a sort, new data, or a change in what is expanded.
1746
+ this._extendBase = null;
1747
+ this._selectionAnchorId = null;
1636
1748
  /*
1637
1749
  * And the single place that notices the data gaining or losing its sections. The header owns
1638
1750
  * the sort controls, so it has to be rebuilt when the answer flips — otherwise a table that was
@@ -1647,9 +1759,9 @@ export class Table extends Component {
1647
1759
  if (this._headers && this._spanning !== this._headerSpanning) this._renderHeader();
1648
1760
  const fragment = document.createDocumentFragment();
1649
1761
  this._rowMeta = new WeakMap();
1762
+ this._rowNames = new WeakMap();
1650
1763
  this._rowElements = new Map();
1651
- this._rovingCell = null;
1652
- this._rovingMatched = false;
1764
+ this._rovingEl = null;
1653
1765
  const projected = this._projectedData();
1654
1766
  this._hierarchyMeta = new Map(projected.map((entry) => [entry.id, entry]));
1655
1767
  if (this._data.length === 0) {
@@ -1673,6 +1785,7 @@ export class Table extends Component {
1673
1785
  this._syncSelectAll();
1674
1786
  this._syncGrowing();
1675
1787
  if (this._stacked) this._applyStackedRoles(true);
1788
+ this._syncRoving(focus);
1676
1789
  }
1677
1790
 
1678
1791
  /**
@@ -1717,6 +1830,9 @@ export class Table extends Component {
1717
1830
  tr.setAttribute('aria-level', String(tree.depth + 1));
1718
1831
  if (tree.hasChildren) tr.setAttribute('aria-expanded', String(this._expanded.has(id)));
1719
1832
  }
1833
+ // Rows take the roving focus in row mode, and in a cell-focus treegrid too, where ArrowLeft
1834
+ // from the first cell lands on the row so the branch can be expanded or collapsed.
1835
+ if (this._interactive && (this._focusMode === 'row' || this._hierarchy)) tr.tabIndex = -1;
1720
1836
  this._rowMeta.set(tr, { row, id, index, visibleIndex: tree.visibleIndex });
1721
1837
  const elements = this._rowElements.get(id) ?? [];
1722
1838
  elements.push(tr);
@@ -1737,6 +1853,7 @@ export class Table extends Component {
1737
1853
  if (content && typeof content === 'object' && typeof content.nodeType === 'number') {
1738
1854
  cell.append(content);
1739
1855
  } else if (content != null) cell.append(document.createTextNode(String(content)));
1856
+ if (this._focusMode === 'cell') cell.tabIndex = -1;
1740
1857
  tr.append(cell);
1741
1858
  return tr;
1742
1859
  }
@@ -1744,7 +1861,7 @@ export class Table extends Component {
1744
1861
  if (this.options.rowReorder) {
1745
1862
  const handle = h('button', {
1746
1863
  class: 'zx-table__row-handle', type: 'button', draggable: true,
1747
- dataset: { rowHandle: '' }, ariaLabel: `Move row ${index + 1}`,
1864
+ dataset: { rowHandle: '' },
1748
1865
  ariaPressed: String(Object.is(this._grabbedId, id)),
1749
1866
  disabled: Boolean(this._sort && this.options.sortMode === 'local'),
1750
1867
  title: this._sort && this.options.sortMode === 'local' ? 'Clear sorting to reorder rows' : null
@@ -1753,11 +1870,13 @@ export class Table extends Component {
1753
1870
  }
1754
1871
 
1755
1872
  if (this.options.selectable === 'multi') {
1873
+ // Out of the Tab order: Space on the focused row does what the checkbox does, and thirty
1874
+ // checkboxes would otherwise be thirty Tab stops in front of whatever follows the table.
1756
1875
  const checkbox = h('input', {
1757
1876
  class: 'zx-table__checkbox zx-table__row-checkbox',
1758
1877
  type: 'checkbox',
1759
- checked: this._selected.has(id),
1760
- ariaLabel: `Select row ${index + 1}`
1878
+ tabIndex: -1,
1879
+ checked: this._selected.has(id)
1761
1880
  });
1762
1881
  tr.append(h('td', { class: 'zx-table__selection-cell' }, checkbox));
1763
1882
  }
@@ -1769,21 +1888,42 @@ export class Table extends Component {
1769
1888
  // Written unconditionally: `responsive` can be switched on after the rows exist, and a
1770
1889
  // stacked cell has no column header above it to say what its value means.
1771
1890
  cell.dataset.label = column.label;
1891
+ cell.dataset.column = column.id;
1772
1892
  if (column.popin === false) cell.dataset.popin = 'false';
1773
- if (this._editMode !== false) {
1774
- cell.dataset.column = column.id;
1775
- if (resolveEditable(column, row) !== false) {
1776
- cell.dataset.editable = 'true';
1777
- cell.tabIndex = -1;
1778
- this._offerRovingCell(cell, id, column.id);
1779
- }
1780
- }
1893
+ if (this._focusMode === 'cell') cell.tabIndex = -1;
1894
+ if (this._editMode !== false && resolveEditable(column, row) !== false) cell.dataset.editable = 'true';
1781
1895
  this._renderCellContent(cell, column, row, index, tree);
1782
1896
  tr.append(cell);
1783
1897
  });
1898
+ this._rowNames.set(tr, tableRowLabel([...tr.cells].map((cell) => cell.textContent)));
1899
+ this._labelRowControls(tr);
1784
1900
  return tr;
1785
1901
  }
1786
1902
 
1903
+ /**
1904
+ * Names a row's own controls after the row's content rather than its position: "Select Acme
1905
+ * GmbH", not "Select row 3", which tells a screen reader user nothing and changes on every sort.
1906
+ * The position is the fallback for a row without any text.
1907
+ * @param {HTMLTableRowElement} tr Rendered row.
1908
+ * @returns {void}
1909
+ */
1910
+ _labelRowControls(tr) {
1911
+ const meta = this._rowMeta.get(tr);
1912
+ if (!meta) return;
1913
+ const name = this._rowNames.get(tr) ?? this._message('table.row', 'row %1', [meta.index + 1]);
1914
+ const checkbox = tr.querySelector('.zx-table__row-checkbox');
1915
+ checkbox?.setAttribute('aria-label', this._message('table.selectRow', 'Select %1', [name]));
1916
+ tr.querySelector('.zx-table__row-handle')
1917
+ ?.setAttribute('aria-label', this._message('table.moveRow', 'Move %1', [name]));
1918
+ const toggle = tr.querySelector('.zx-table__tree-toggle');
1919
+ if (toggle) {
1920
+ const expanded = toggle.getAttribute('aria-expanded') === 'true';
1921
+ toggle.setAttribute('aria-label', expanded
1922
+ ? this._message('table.collapseRow', 'Collapse %1', [name])
1923
+ : this._message('table.expandRow', 'Expand %1', [name]));
1924
+ }
1925
+ }
1926
+
1787
1927
  /**
1788
1928
  * Fills a cell with the column's rendered value, replacing anything already in it.
1789
1929
  * @param {HTMLTableCellElement} cell Target cell.
@@ -1808,17 +1948,21 @@ export class Table extends Component {
1808
1948
  branch.style.setProperty('--zx-table-depth', String(tree.depth));
1809
1949
  if (tree.hasChildren) {
1810
1950
  const expanded = this._expanded.has(tree.id);
1951
+ // Out of the Tab order like the row checkbox: ArrowRight and ArrowLeft on the row expand
1952
+ // and collapse it. The pointer still has the button.
1811
1953
  branch.append(h('button', {
1812
1954
  class: 'zx-table__tree-toggle',
1813
1955
  type: 'button',
1814
- ariaExpanded: String(expanded),
1815
- ariaLabel: `${expanded ? 'Collapse' : 'Expand'} row ${index + 1}`
1956
+ tabIndex: -1,
1957
+ ariaExpanded: String(expanded)
1816
1958
  }, icon(expanded ? 'chevron-down' : 'chevron-right', { size: 14 })));
1817
1959
  } else {
1818
1960
  branch.append(h('span', { class: 'zx-table__tree-spacer', ariaHidden: 'true' }));
1819
1961
  }
1820
1962
  branch.append(content);
1821
1963
  cell.replaceChildren(branch);
1964
+ // A cell re-rendered after an edit rebuilds its disclosure, which needs its name again.
1965
+ if (cell.parentElement) this._labelRowControls(/** @type {HTMLTableRowElement} */ (cell.parentElement));
1822
1966
  }
1823
1967
 
1824
1968
  /** @param {MouseEvent} event @returns {void} */
@@ -1850,7 +1994,9 @@ export class Table extends Component {
1850
1994
  return;
1851
1995
  }
1852
1996
 
1853
- if (isInteractiveCellContent(target)) return;
1997
+ // Only an editable table holds a click on a control inside a cell back from the row: there the
1998
+ // click could otherwise open an editor. A read-only table activates the row as it always has.
1999
+ if (this._editMode !== false && isInteractiveCellContent(target)) return;
1854
2000
 
1855
2001
  if (this._editMode !== false && this._editTrigger === 'single') {
1856
2002
  const cell = target?.closest('td[data-editable="true"]');
@@ -1860,6 +2006,18 @@ export class Table extends Component {
1860
2006
  }
1861
2007
  }
1862
2008
 
2009
+ this._activateRow(meta, event);
2010
+ }
2011
+
2012
+ /**
2013
+ * Activates a row: the click path, shared by Enter on a focused row or cell.
2014
+ * @param {{row: TableRow, id: unknown, index: number, visibleIndex?: number}} meta Row metadata.
2015
+ * @param {Event} event Originating click or keydown.
2016
+ * @returns {void}
2017
+ * @fires Table#rowclick
2018
+ * @fires Table#selectionchange
2019
+ */
2020
+ _activateRow(meta, event) {
1863
2021
  this.emit('rowclick', { ...meta, event });
1864
2022
  // The click still reaches the host — collapsing a section is a reasonable thing to hang off it —
1865
2023
  // but it does not select the heading, which carries no checkbox in `multi` either.
@@ -1867,6 +2025,7 @@ export class Table extends Component {
1867
2025
  && !this._isSpanningRow(meta.row)) {
1868
2026
  this._selected = new Set([meta.id]);
1869
2027
  this._selectionAnchorId = meta.id;
2028
+ this._extendBase = null;
1870
2029
  this._syncSelection();
1871
2030
  this._emitSelectionChange();
1872
2031
  }
@@ -1881,7 +2040,7 @@ export class Table extends Component {
1881
2040
  if (!rowElement || !this._tbody.contains(rowElement)) return;
1882
2041
  const meta = this._rowMeta.get(rowElement);
1883
2042
  if (!meta) return;
1884
- if (isInteractiveCellContent(target)) return;
2043
+ if (this._editMode !== false && isInteractiveCellContent(target)) return;
1885
2044
  if (this._editMode !== false && this._editTrigger === 'double') {
1886
2045
  const cell = target?.closest('td[data-editable="true"]');
1887
2046
  if (cell && this._tbody.contains(cell)) {
@@ -2027,7 +2186,7 @@ export class Table extends Component {
2027
2186
  ...props,
2028
2187
  class: textarea ? 'zx-table__editor-input zx-table__editor-textarea' : 'zx-table__editor-input',
2029
2188
  ...(textarea ? {} : { type: 'text' }),
2030
- ariaLabel: props.ariaLabel ?? `${column.label} row ${index + 1}`,
2189
+ ariaLabel: props.ariaLabel ?? `${tableColumnName(column)} row ${index + 1}`,
2031
2190
  value: formatCellValue(column, value, row)
2032
2191
  }));
2033
2192
  host.append(input);
@@ -2046,8 +2205,10 @@ export class Table extends Component {
2046
2205
  const control = entry?.control;
2047
2206
  if (!control || typeof control.focus !== 'function') return;
2048
2207
  control.focus();
2049
- // Opening a text editor selects its content, so typing replaces the old value.
2050
- if (entry.type === 'text' && typeof control.select === 'function') control.select();
2208
+ // Opening a text, number, or date editor selects its content, so typing replaces the old value
2209
+ // ("6" over "1.00" gives 6, not 1.006). Selected after focus: NumberField rewrites its text on
2210
+ // focus, from the grouped display to a plain re-parseable number.
2211
+ if (editorSelectsOnEntry(entry.type) && typeof control.select === 'function') control.select();
2051
2212
  }
2052
2213
 
2053
2214
  /**
@@ -2241,16 +2402,6 @@ export class Table extends Component {
2241
2402
  return null;
2242
2403
  }
2243
2404
 
2244
- /** @param {Event} event @returns {void} */
2245
- _handleEditFocusIn(event) {
2246
- const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : null);
2247
- const cell = target?.closest('td[data-editable="true"]');
2248
- if (!cell || !this._tbody.contains(cell)) return;
2249
- const meta = this._rowMeta.get(cell.parentElement);
2250
- if (!meta) return;
2251
- this._setRovingCell(/** @type {HTMLTableCellElement} */ (cell), meta.id, String(cell.dataset.column));
2252
- }
2253
-
2254
2405
  /** @param {Event} event @returns {void} @fires Table#editcommit */
2255
2406
  _handleDocumentPointerDown(event) {
2256
2407
  if (!this._edit) return;
@@ -2262,31 +2413,366 @@ export class Table extends Component {
2262
2413
  this.commitEdit();
2263
2414
  }
2264
2415
 
2416
+ /** @returns {HTMLTableRowElement[]} Rendered body rows in display order, section rows included. */
2417
+ _navRows() {
2418
+ return /** @type {HTMLTableRowElement[]} */ ([...this._tbody.children]
2419
+ .filter((row) => row.hasAttribute('data-row')));
2420
+ }
2421
+
2422
+ /**
2423
+ * Cells that take focus while the table moves focus by cell: the data cells, or the single cell of
2424
+ * a section row. The selection and reorder cells are not among them; their controls have keys of
2425
+ * their own on the row.
2426
+ * @param {HTMLTableRowElement} tr Body row.
2427
+ * @returns {HTMLTableCellElement[]}
2428
+ */
2429
+ _navCells(tr) {
2430
+ return /** @type {HTMLTableCellElement[]} */ ([...tr.cells].filter((cell) =>
2431
+ cell.hasAttribute('data-column') || cell.classList.contains('zx-table__section-cell')));
2432
+ }
2433
+
2434
+ /**
2435
+ * The cell of a row that matches the current column: by id, else by position, so a section row
2436
+ * (one cell) or a hidden column still lands somewhere sensible.
2437
+ * @param {HTMLTableRowElement} tr Body row.
2438
+ * @returns {HTMLElement}
2439
+ */
2440
+ _cellFor(tr) {
2441
+ const cells = this._navCells(tr);
2442
+ if (cells.length === 0) return tr;
2443
+ const columnId = this._current?.columnId;
2444
+ const byId = columnId == null ? null : cells.find((cell) => cell.dataset.column === columnId);
2445
+ return byId ?? cells[Math.min(Math.max(this._current?.columnIndex ?? 0, 0), cells.length - 1)];
2446
+ }
2447
+
2448
+ /** @param {HTMLTableRowElement} tr @returns {HTMLElement} Element of a row that holds the Tab stop. */
2449
+ _rovingTarget(tr) {
2450
+ if (this._focusMode === 'row' || this._hierarchy && this._current?.onRow) return tr;
2451
+ return this._cellFor(tr);
2452
+ }
2453
+
2454
+ /** @param {HTMLElement} element New roving Tab stop. @returns {void} */
2455
+ _setRoving(element) {
2456
+ if (this._rovingEl && this._rovingEl !== element) this._rovingEl.tabIndex = -1;
2457
+ element.tabIndex = 0;
2458
+ this._rovingEl = element;
2459
+ }
2460
+
2461
+ /**
2462
+ * Records which part of a row holds focus before the body is rebuilt.
2463
+ * @returns {{part: 'row'|'cell'|'checkbox'|'toggle'|'handle'}|null} Null when focus is elsewhere.
2464
+ */
2465
+ _captureFocus() {
2466
+ const active = typeof document === 'undefined' ? null : document.activeElement;
2467
+ // The frame holds focus while the table is empty; the first row takes it back.
2468
+ if (active && active === this._scroll && this._scroll.hasAttribute('data-empty-focus')) return { part: 'row' };
2469
+ if (!active || !this._tbody?.contains(active)) return null;
2470
+ const tr = active.closest('tr[data-row]');
2471
+ if (!tr) return null;
2472
+ if (active === tr) return { part: 'row' };
2473
+ if (active.closest('.zx-table__row-checkbox')) return { part: 'checkbox' };
2474
+ if (active.closest('.zx-table__tree-toggle')) return { part: 'toggle' };
2475
+ if (active.closest('.zx-table__row-handle')) return { part: 'handle' };
2476
+ return { part: this._focusMode === 'cell' ? 'cell' : 'row' };
2477
+ }
2478
+
2479
+ /**
2480
+ * Puts the Tab stop back on the current row after a render — the same row by id; else, when a
2481
+ * collapsed ancestor hid it, that ancestor; else the row now at its old position — and gives it
2482
+ * focus again only when the body had focus before. The focus is restored without scrolling: a
2483
+ * table refreshed by polling must not pull the page back to itself.
2484
+ * @param {{part: string}|null} focus What `_captureFocus()` found before the render.
2485
+ * @returns {void}
2486
+ */
2487
+ _syncRoving(focus) {
2488
+ if (!this._interactive) return;
2489
+ const rows = this._navRows();
2490
+ this._syncEmptyFocus(rows.length === 0 && focus !== null);
2491
+ if (rows.length === 0) return;
2492
+ let tr = this._current ? this._rowElements.get(this._current.id)?.[0] : undefined;
2493
+ if (!tr && this._current && this._hierarchy) {
2494
+ tr = this._renderedAncestor(this._current.id);
2495
+ if (tr) {
2496
+ const meta = this._rowMeta.get(tr);
2497
+ this._current = { ...this._current, id: meta.id, visibleIndex: meta.visibleIndex, onRow: true };
2498
+ }
2499
+ }
2500
+ if (!tr) {
2501
+ tr = rows[Math.min(Math.max(this._current?.visibleIndex ?? 0, 0), rows.length - 1)];
2502
+ if (this._current) {
2503
+ const meta = this._rowMeta.get(tr);
2504
+ this._current = { ...this._current, id: meta.id, visibleIndex: meta.visibleIndex };
2505
+ }
2506
+ }
2507
+ const element = this._rovingTarget(tr);
2508
+ this._setRoving(element);
2509
+ if (!focus) return;
2510
+ const control = {
2511
+ checkbox: '.zx-table__row-checkbox', toggle: '.zx-table__tree-toggle', handle: '.zx-table__row-handle:not(:disabled)'
2512
+ }[focus.part];
2513
+ const target = control ? /** @type {HTMLElement|null} */ (tr.querySelector(control)) ?? element : element;
2514
+ target.focus({ preventScroll: true });
2515
+ }
2516
+
2265
2517
  /**
2266
- * Keeps exactly one editable cell in the tab order, preferring the last focused one.
2267
- * @param {HTMLTableCellElement} cell Newly rendered editable cell.
2518
+ * The nearest ancestor of a row that is rendered — where focus goes when collapsing a branch
2519
+ * hid the focused row.
2268
2520
  * @param {unknown} id Row id.
2269
- * @param {string} columnId Column id.
2521
+ * @returns {HTMLTableRowElement|undefined}
2522
+ */
2523
+ _renderedAncestor(id) {
2524
+ if (!this._hierarchy) return undefined;
2525
+ const seen = new Set();
2526
+ let row = this.getRow(id);
2527
+ while (row) {
2528
+ const parentId = readRowValue(this._hierarchy.parentId, row);
2529
+ if (parentId == null || seen.has(parentId)) return undefined;
2530
+ seen.add(parentId);
2531
+ const element = this._rowElements.get(parentId)?.[0];
2532
+ if (element) return element;
2533
+ row = this.getRow(parentId);
2534
+ }
2535
+ return undefined;
2536
+ }
2537
+
2538
+ /**
2539
+ * While the table has no rows to focus, its frame takes focus instead of letting it fall to the
2540
+ * document — and gives the Tab order its old shape back once rows return.
2541
+ * @param {boolean} hold Whether focus was in the body and there is now no row for it.
2542
+ * @returns {void}
2543
+ */
2544
+ _syncEmptyFocus(hold) {
2545
+ const frame = this._scroll;
2546
+ if (hold) {
2547
+ if (!frame.hasAttribute('tabindex')) {
2548
+ frame.tabIndex = -1;
2549
+ frame.setAttribute('data-empty-focus', '');
2550
+ }
2551
+ frame.focus({ preventScroll: true });
2552
+ } else if (frame.hasAttribute('data-empty-focus')) {
2553
+ frame.removeAttribute('tabindex');
2554
+ frame.removeAttribute('data-empty-focus');
2555
+ }
2556
+ }
2557
+
2558
+ /** @param {Event} event @returns {void} */
2559
+ _handleFocusIn(event) {
2560
+ if (!this._interactive) return;
2561
+ const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : null);
2562
+ const tr = /** @type {HTMLTableRowElement|null} */ (target?.closest('tr[data-row]') ?? null);
2563
+ if (!tr || tr.parentElement !== this._tbody) return;
2564
+ const meta = this._rowMeta.get(tr);
2565
+ if (!meta) return;
2566
+ const current = {
2567
+ id: meta.id,
2568
+ visibleIndex: meta.visibleIndex,
2569
+ columnId: this._current?.columnId ?? null,
2570
+ columnIndex: this._current?.columnIndex ?? 0,
2571
+ onRow: false
2572
+ };
2573
+ let element = /** @type {HTMLElement} */ (tr);
2574
+ if (this._focusMode === 'cell') {
2575
+ const cells = this._navCells(tr);
2576
+ const cell = cells.find((candidate) => candidate === target || candidate.contains(target)) ?? null;
2577
+ if (target === tr && this._hierarchy) {
2578
+ current.onRow = true;
2579
+ } else if (cell) {
2580
+ element = cell;
2581
+ // A section's single cell keeps the column the reader came from, so moving on past the
2582
+ // section returns to it.
2583
+ if (cell.dataset.column) {
2584
+ current.columnId = cell.dataset.column;
2585
+ current.columnIndex = cells.indexOf(cell);
2586
+ }
2587
+ } else {
2588
+ this._current = current;
2589
+ element = this._cellFor(tr);
2590
+ }
2591
+ }
2592
+ this._current = current;
2593
+ this._setRoving(element);
2594
+ }
2595
+
2596
+ /**
2597
+ * The APG grid and treegrid keyboard model on the focused row or cell. The row's own checkbox and
2598
+ * disclosure answer the navigation keys too, so a pointer user who clicked one can carry on by
2599
+ * keyboard; anything else inside a cell — a link, a button, an open editor — keeps its keys.
2600
+ * @param {KeyboardEvent} event Keydown event.
2601
+ * @returns {void}
2602
+ * @fires Table#rowclick
2603
+ * @fires Table#selectionchange
2604
+ * @fires Table#rowtoggle
2605
+ */
2606
+ _handleGridKeydown(event) {
2607
+ if (!this._interactive || event.defaultPrevented || event.altKey) return;
2608
+ const target = /** @type {HTMLElement|null} */ (event.target?.nodeType === 1 ? event.target : null);
2609
+ if (!target || this._edit && isInsideEditor(target)) return;
2610
+ const tr = /** @type {HTMLTableRowElement|null} */ (target.closest('tr[data-row]'));
2611
+ if (!tr || tr.parentElement !== this._tbody) return;
2612
+ const meta = this._rowMeta.get(tr);
2613
+ if (!meta) return;
2614
+ const cellMode = this._focusMode === 'cell';
2615
+ const cells = cellMode ? this._navCells(tr) : [];
2616
+ const onRow = target === tr;
2617
+ const onCell = cells.includes(/** @type {HTMLTableCellElement} */ (target));
2618
+ const ownControl = target.matches('.zx-table__row-checkbox, .zx-table__tree-toggle');
2619
+ if (!onRow && !onCell && !ownControl) return;
2620
+ const key = event.key;
2621
+ const mod = event.ctrlKey || event.metaKey;
2622
+
2623
+ if (mod && (key === 'a' || key === 'A')) {
2624
+ if (this.options.selectable !== 'multi') return;
2625
+ event.preventDefault();
2626
+ this._selectAllRendered();
2627
+ return;
2628
+ }
2629
+ if ((key === 'Enter' || key === ' ') && !ownControl) {
2630
+ if (mod) return;
2631
+ event.preventDefault();
2632
+ if (key === ' ' && this.options.selectable !== false && !this._isSpanningRow(meta.row)) {
2633
+ this._toggleRowSelection(meta);
2634
+ } else {
2635
+ this._activateRow(meta, event);
2636
+ }
2637
+ return;
2638
+ }
2639
+ if (this._hierarchy && (key === 'ArrowLeft' || key === 'ArrowRight') && !mod) {
2640
+ const entry = this._hierarchyMeta?.get(meta.id);
2641
+ if (!entry) return;
2642
+ event.preventDefault();
2643
+ const action = treegridArrowAction(key, {
2644
+ focus: onCell ? 'cell' : 'row',
2645
+ cells: cellMode,
2646
+ column: onCell ? cells.indexOf(/** @type {HTMLTableCellElement} */ (target)) : 0,
2647
+ columns: cells.length,
2648
+ hasChildren: entry.hasChildren,
2649
+ expanded: this._expanded.has(meta.id),
2650
+ hasParent: entry.parentId != null
2651
+ });
2652
+ if (action) this._applyTreeAction(action, tr, meta, entry.parentId);
2653
+ return;
2654
+ }
2655
+
2656
+ const rows = this._navRows();
2657
+ const rowIndex = rows.indexOf(tr);
2658
+ const byCell = cellMode && !onRow;
2659
+ const column = onCell ? cells.indexOf(/** @type {HTMLTableCellElement} */ (target))
2660
+ : Math.max(0, cells.indexOf(/** @type {HTMLTableCellElement} */ (this._cellFor(tr))));
2661
+ const next = tableGridTarget({ row: rowIndex, column }, key, {
2662
+ rows: rows.length, columns: byCell ? cells.length : 1, page: this._pageSize(rows), ctrl: mod, cells: byCell
2663
+ });
2664
+ if (!next) return;
2665
+ event.preventDefault();
2666
+ const targetRow = rows[next.row];
2667
+ if (!byCell) {
2668
+ targetRow.focus();
2669
+ } else if (next.row === rowIndex || key === 'Home' || key === 'End') {
2670
+ // A horizontal move, or a jump to a corner: pick the cell by position in the target row.
2671
+ const targetCells = this._navCells(targetRow);
2672
+ const last = next.column >= cells.length - 1 && key !== 'Home';
2673
+ targetCells[last ? targetCells.length - 1 : Math.min(next.column, targetCells.length - 1)]?.focus();
2674
+ } else {
2675
+ this._cellFor(targetRow).focus();
2676
+ }
2677
+ if (event.shiftKey && this.options.selectable === 'multi' && next.row !== rowIndex) {
2678
+ this._extendSelection(meta, this._rowMeta.get(targetRow));
2679
+ }
2680
+ }
2681
+
2682
+ /**
2683
+ * Carries out one treegrid arrow-key action on a row.
2684
+ * @param {{action: string, column?: number}} action What `treegridArrowAction()` decided.
2685
+ * @param {HTMLTableRowElement} tr Focused row.
2686
+ * @param {{id: unknown}} meta Row metadata.
2687
+ * @param {unknown} parentId Parent row id, or null at the root.
2270
2688
  * @returns {void}
2689
+ * @fires Table#rowtoggle
2271
2690
  */
2272
- _offerRovingCell(cell, id, columnId) {
2273
- if (this._rovingMatched) return;
2274
- const preferred = this._rovingKey !== null
2275
- && Object.is(this._rovingKey.id, id) && this._rovingKey.columnId === columnId;
2276
- if (!preferred && this._rovingCell) return;
2277
- if (preferred && this._rovingCell) this._rovingCell.tabIndex = -1;
2278
- cell.tabIndex = 0;
2279
- this._rovingCell = cell;
2280
- this._rovingMatched = preferred;
2691
+ _applyTreeAction(action, tr, meta, parentId) {
2692
+ if (action.action === 'expand') this.expandRow(meta.id);
2693
+ else if (action.action === 'collapse') this.collapseRow(meta.id);
2694
+ else if (action.action === 'parent') this._rowElements.get(parentId)?.[0]?.focus();
2695
+ else if (action.action === 'child') {
2696
+ const rows = this._navRows();
2697
+ rows[rows.indexOf(tr) + 1]?.focus();
2698
+ } else if (action.action === 'row') tr.focus();
2699
+ else if (action.action === 'cell') this._navCells(tr)[action.column ?? 0]?.focus();
2700
+ }
2701
+
2702
+ /**
2703
+ * How many rows PageUp and PageDown move: what fits in the scroll region when the table scrolls
2704
+ * on its own, else ten.
2705
+ * @param {HTMLTableRowElement[]} rows Rendered rows.
2706
+ * @returns {number}
2707
+ */
2708
+ _pageSize(rows) {
2709
+ const scroller = this._scroll;
2710
+ if (!scroller || !rows[0] || scroller.scrollHeight <= scroller.clientHeight + 1) return 10;
2711
+ const header = this.options.stickyHeader ? this._thead.getBoundingClientRect().height : 0;
2712
+ return tablePageSize(scroller.clientHeight - header, rows[0].getBoundingClientRect().height);
2713
+ }
2714
+
2715
+ /**
2716
+ * Space on a row: toggles it in `multi`, and in `single` selects it or clears the selection.
2717
+ * @param {{row: TableRow, id: unknown, index: number, visibleIndex?: number}} meta Row metadata.
2718
+ * @returns {void}
2719
+ * @fires Table#selectionchange
2720
+ */
2721
+ _toggleRowSelection(meta) {
2722
+ const selected = this._selected.has(meta.id);
2723
+ if (this.options.selectable === 'multi') {
2724
+ this._toggleMultiSelection(meta, !selected, false);
2725
+ return;
2726
+ }
2727
+ this._selected = selected ? new Set() : new Set([meta.id]);
2728
+ this._selectionAnchorId = meta.id;
2729
+ this._extendBase = null;
2730
+ this._syncSelection();
2731
+ this._emitSelectionChange();
2732
+ }
2733
+
2734
+ /**
2735
+ * Ctrl/Cmd+A: selects every rendered row that can hold a selection, as the header checkbox does.
2736
+ * @returns {void}
2737
+ * @fires Table#selectionchange
2738
+ */
2739
+ _selectAllRendered() {
2740
+ const next = new Set(this._selected);
2741
+ for (const row of this._selectableData()) next.add(this._idFor(row));
2742
+ this._extendBase = null;
2743
+ if (sameSet(next, this._selected)) return;
2744
+ this._selected = next;
2745
+ this._syncSelection();
2746
+ this._emitSelectionChange();
2281
2747
  }
2282
2748
 
2283
- /** @param {HTMLTableCellElement} cell @param {unknown} id @param {string} columnId @returns {void} */
2284
- _setRovingCell(cell, id, columnId) {
2285
- if (this._rovingCell && this._rovingCell !== cell) this._rovingCell.tabIndex = -1;
2286
- cell.tabIndex = 0;
2287
- this._rovingCell = cell;
2288
- this._rovingMatched = true;
2289
- this._rovingKey = { id, columnId };
2749
+ /**
2750
+ * Shift+Arrow: selects the rows between the anchor and the row focus moved to, on top of what was
2751
+ * selected before the extension began — moving back shrinks the range again.
2752
+ * @param {{id: unknown}} from Row focus moved from.
2753
+ * @param {{id: unknown}|undefined} to Row focus moved to.
2754
+ * @returns {void}
2755
+ * @fires Table#selectionchange
2756
+ */
2757
+ _extendSelection(from, to) {
2758
+ if (!to) return;
2759
+ const visible = this._visibleData();
2760
+ const indexOf = (id) => visible.findIndex((row) => Object.is(this._idFor(row), id));
2761
+ if (this._extendBase === null) {
2762
+ this._extendBase = new Set(this._selected);
2763
+ if (indexOf(this._selectionAnchorId) < 0) this._selectionAnchorId = from.id;
2764
+ }
2765
+ const anchor = indexOf(this._selectionAnchorId);
2766
+ const end = indexOf(to.id);
2767
+ if (anchor < 0 || end < 0) return;
2768
+ const next = new Set(this._extendBase);
2769
+ for (let index = Math.min(anchor, end); index <= Math.max(anchor, end); index += 1) {
2770
+ if (!this._isSpanningRow(visible[index])) next.add(this._idFor(visible[index]));
2771
+ }
2772
+ if (sameSet(next, this._selected)) return;
2773
+ this._selected = next;
2774
+ this._syncSelection();
2775
+ this._emitSelectionChange();
2290
2776
  }
2291
2777
 
2292
2778
  /**
@@ -2322,6 +2808,7 @@ export class Table extends Component {
2322
2808
  this._selected.delete(meta.id);
2323
2809
  }
2324
2810
  this._selectionAnchorId = meta.id;
2811
+ this._extendBase = null;
2325
2812
  this._syncSelection();
2326
2813
  this._emitSelectionChange();
2327
2814
  }
@@ -2428,6 +2915,7 @@ export class Table extends Component {
2428
2915
  const previousSize = this._selected.size;
2429
2916
  for (const id of this._selected) if (!valid.has(id)) this._selected.delete(id);
2430
2917
  if (!valid.has(this._selectionAnchorId)) this._selectionAnchorId = null;
2918
+ this._extendBase = null;
2431
2919
  return previousSize !== this._selected.size;
2432
2920
  }
2433
2921
 
@@ -2496,6 +2984,113 @@ export class Table extends Component {
2496
2984
  * @type {CustomEvent<TableEditInvalidDetail>}
2497
2985
  */
2498
2986
 
2987
+ /**
2988
+ * Where a navigation key moves focus in the table body: the arithmetic of the APG grid keyboard
2989
+ * model, kept apart from the DOM. Positions are zero-based and every move is clamped — focus never
2990
+ * wraps, as the APG asks of a grid.
2991
+ *
2992
+ * With `cells` false (row focus) only the vertical keys move: ArrowUp/ArrowDown by one row,
2993
+ * PageUp/PageDown by `page`, Home/End (with or without Ctrl) to the first or last row. With `cells`
2994
+ * true ArrowLeft/ArrowRight move one cell, Home/End go to the first or last cell of the row, and
2995
+ * Ctrl+Home/Ctrl+End to the first cell of the first row or the last cell of the last row.
2996
+ * @param {{row: number, column?: number}} position Current position.
2997
+ * @param {string} key `KeyboardEvent.key`.
2998
+ * @param {{rows: number, columns?: number, page?: number, ctrl?: boolean, cells?: boolean}} grid
2999
+ * Row and column counts, the page size, whether Ctrl/Cmd is held, and whether focus is on cells.
3000
+ * @returns {{row: number, column: number}|null} New position, or null when the key is not a move.
3001
+ */
3002
+ export function tableGridTarget(position, key, grid) {
3003
+ const rows = Math.trunc(Number(grid?.rows) || 0);
3004
+ if (rows <= 0) return null;
3005
+ const columns = Math.max(1, Math.trunc(Number(grid?.columns) || 1));
3006
+ const page = Math.max(1, Math.trunc(Number(grid?.page) || 10));
3007
+ const clamp = (value, max) => Math.min(Math.max(Math.trunc(Number(value) || 0), 0), max);
3008
+ const row = clamp(position?.row, rows - 1);
3009
+ const column = clamp(position?.column, columns - 1);
3010
+ const cells = Boolean(grid?.cells);
3011
+ const ctrl = Boolean(grid?.ctrl);
3012
+ switch (key) {
3013
+ case 'ArrowUp': return { row: Math.max(0, row - 1), column };
3014
+ case 'ArrowDown': return { row: Math.min(rows - 1, row + 1), column };
3015
+ case 'PageUp': return { row: Math.max(0, row - page), column };
3016
+ case 'PageDown': return { row: Math.min(rows - 1, row + page), column };
3017
+ case 'ArrowLeft': return cells ? { row, column: Math.max(0, column - 1) } : null;
3018
+ case 'ArrowRight': return cells ? { row, column: Math.min(columns - 1, column + 1) } : null;
3019
+ case 'Home':
3020
+ if (!cells) return { row: 0, column };
3021
+ return ctrl ? { row: 0, column: 0 } : { row, column: 0 };
3022
+ case 'End':
3023
+ if (!cells) return { row: rows - 1, column };
3024
+ return ctrl ? { row: rows - 1, column: columns - 1 } : { row, column: columns - 1 };
3025
+ default: return null;
3026
+ }
3027
+ }
3028
+
3029
+ /**
3030
+ * How many rows a PageUp or PageDown moves: the rows that fit in the visible height, less one so
3031
+ * the row focus leaves stays in view as context. Falls back when either height is unknown.
3032
+ * @param {number} viewport Visible height of the scroll region, below any sticky header.
3033
+ * @param {number} rowHeight Height of one row.
3034
+ * @param {number} [fallback=10] Rows to move when the heights are unusable.
3035
+ * @returns {number} At least 1.
3036
+ */
3037
+ export function tablePageSize(viewport, rowHeight, fallback = 10) {
3038
+ const visible = Number(viewport);
3039
+ const height = Number(rowHeight);
3040
+ if (!(visible > 0) || !(height > 0)) return Math.max(1, Math.trunc(fallback) || 10);
3041
+ return Math.max(1, Math.floor(visible / height) - 1);
3042
+ }
3043
+
3044
+ /**
3045
+ * ArrowLeft and ArrowRight in a treegrid, after the APG Treegrid pattern.
3046
+ *
3047
+ * Row focus (`focus: 'row'`): ArrowRight expands a collapsed branch; on an expanded branch it moves
3048
+ * into the first child — or, when cells take focus too (`cells`), onto the row's first cell, which
3049
+ * is also where it goes from a leaf. ArrowLeft collapses an expanded branch and otherwise moves to
3050
+ * the parent row. Cell focus: ArrowRight moves one cell right; ArrowLeft one cell left, and from the
3051
+ * first cell onto the row itself, where the branch can be expanded or collapsed.
3052
+ * @param {'ArrowLeft'|'ArrowRight'|string} key `KeyboardEvent.key`.
3053
+ * @param {{focus: 'row'|'cell', cells?: boolean, column?: number, columns?: number,
3054
+ * hasChildren?: boolean, expanded?: boolean, hasParent?: boolean}} state Focused row and cell.
3055
+ * @returns {{action: 'expand'|'collapse'|'child'|'parent'|'row'|'cell', column?: number}|null}
3056
+ * What to do, or null when focus stays where it is.
3057
+ */
3058
+ export function treegridArrowAction(key, state) {
3059
+ const right = key === 'ArrowRight';
3060
+ if (!right && key !== 'ArrowLeft') return null;
3061
+ if (state?.focus === 'cell') {
3062
+ const column = Math.max(0, Math.trunc(Number(state.column) || 0));
3063
+ const columns = Math.max(1, Math.trunc(Number(state.columns) || 1));
3064
+ if (right) return column < columns - 1 ? { action: 'cell', column: column + 1 } : null;
3065
+ return column > 0 ? { action: 'cell', column: column - 1 } : { action: 'row' };
3066
+ }
3067
+ const branch = Boolean(state?.hasChildren);
3068
+ const expanded = branch && Boolean(state?.expanded);
3069
+ if (right) {
3070
+ if (branch && !expanded) return { action: 'expand' };
3071
+ if (state?.cells) return { action: 'cell', column: 0 };
3072
+ return expanded ? { action: 'child' } : null;
3073
+ }
3074
+ if (expanded) return { action: 'collapse' };
3075
+ return state?.hasParent ? { action: 'parent' } : null;
3076
+ }
3077
+
3078
+ /**
3079
+ * The name a row goes by in the labels of its own controls ("Select …", "Expand …"): the text of
3080
+ * its first cell that has any, whitespace collapsed and long text cut short. Null when no cell has
3081
+ * text, which is when the caller falls back to the row's position.
3082
+ * @param {Array<string|null|undefined>} texts Text of the row's cells, in display order.
3083
+ * @param {number} [max=80] Longest name returned before it is cut with an ellipsis.
3084
+ * @returns {string|null}
3085
+ */
3086
+ export function tableRowLabel(texts, max = 80) {
3087
+ for (const text of Array.isArray(texts) ? texts : []) {
3088
+ const name = String(text ?? '').replace(/\s+/g, ' ').trim();
3089
+ if (name) return name.length > max ? `${name.slice(0, Math.max(1, max - 1)).trimEnd()}…` : name;
3090
+ }
3091
+ return null;
3092
+ }
3093
+
2499
3094
  /**
2500
3095
  * Resolves a row's kind through the `rowKind` accessor and coerces it to the string `rowKinds` is
2501
3096
  * keyed by.
@@ -3008,6 +3603,15 @@ function initialExpanded(option) {
3008
3603
  return new Set(Array.isArray(option) ? option : []);
3009
3604
  }
3010
3605
 
3606
+ /**
3607
+ * Resolves the `variant` option to one the stylesheet knows; anything else is the default frame.
3608
+ * @param {unknown} value Requested variant.
3609
+ * @returns {TableVariant}
3610
+ */
3611
+ export function normalizeTableVariant(value) {
3612
+ return value === 'plain' ? 'plain' : 'default';
3613
+ }
3614
+
3011
3615
  /** @param {unknown} mode @returns {TableEditMode} */
3012
3616
  function normalizeEditMode(mode) {
3013
3617
  if (mode === 'cell' || mode === 'row') return mode;
@@ -3059,6 +3663,47 @@ export function normalizeTableColumns(columns) {
3059
3663
  });
3060
3664
  }
3061
3665
 
3666
+ /**
3667
+ * The name a column goes by wherever it is named rather than shown: its `ariaLabel`, else its
3668
+ * visible label, else its id. The column chooser lists columns by it and cell editors are labelled
3669
+ * with it, so a column with a blank header still has a name in both.
3670
+ * @param {Partial<TableColumn>|null|undefined} column Column descriptor.
3671
+ * @returns {string} Non-empty name, unless the column has no id either.
3672
+ */
3673
+ export function tableColumnName(column) {
3674
+ for (const candidate of [column?.ariaLabel, column?.label]) {
3675
+ const text = candidate == null ? '' : String(candidate).trim();
3676
+ if (text) return text;
3677
+ }
3678
+ return String(column?.id ?? '');
3679
+ }
3680
+
3681
+ /**
3682
+ * The accessible name a column header needs on top of its content, or null when its visible label
3683
+ * already names it. That is the `ariaLabel` when one is given, and the column id when the label is
3684
+ * blank — an icon or actions column — so no header is announced as an empty column.
3685
+ * @param {Partial<TableColumn>|null|undefined} column Column descriptor.
3686
+ * @returns {string|null} Name to set as `aria-label`, or null.
3687
+ */
3688
+ export function tableHeaderName(column) {
3689
+ const label = column?.label == null ? '' : String(column.label).trim();
3690
+ const explicit = column?.ariaLabel == null ? '' : String(column.ariaLabel).trim();
3691
+ if (explicit) return explicit;
3692
+ return label ? null : tableColumnName(column) || null;
3693
+ }
3694
+
3695
+ /**
3696
+ * Whether opening an editor of this type selects its content, so the first keystroke replaces the
3697
+ * value instead of being appended to it. True for the single-line text, number, and date editors;
3698
+ * a textarea keeps its caret, and a select, checkbox, or custom editor keeps its own focus
3699
+ * behaviour.
3700
+ * @param {TableEditorType|string} type Resolved editor type.
3701
+ * @returns {boolean}
3702
+ */
3703
+ export function editorSelectsOnEntry(type) {
3704
+ return type === 'text' || type === 'number' || type === 'date';
3705
+ }
3706
+
3062
3707
  /**
3063
3708
  * Reconciles saved or partial column IDs with every current column.
3064
3709
  * @param {TableColumn[]} columns Current columns.