@zeyos/zx 5.0.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 (154) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/README.md +2 -1
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +9 -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 +288 -14
  7. package/dist/types/components/calendar/calendar.d.ts +67 -5
  8. package/dist/types/components/card-view/card-view.d.ts +7 -4
  9. package/dist/types/components/card-view/record-card.d.ts +11 -0
  10. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  11. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  12. package/dist/types/components/dropdown/dropdown.d.ts +22 -7
  13. package/dist/types/components/field/field.d.ts +30 -0
  14. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  15. package/dist/types/components/fieldset/fieldset.d.ts +7 -0
  16. package/dist/types/components/file-list/file-list.d.ts +35 -3
  17. package/dist/types/components/filter/filter.d.ts +127 -3
  18. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  19. package/dist/types/components/launcher/launcher.d.ts +8 -0
  20. package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
  21. package/dist/types/components/menu-button/menu-button.d.ts +6 -0
  22. package/dist/types/components/message/message.d.ts +14 -1
  23. package/dist/types/components/modal/modal.d.ts +30 -0
  24. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  25. package/dist/types/components/pagination/pagination.d.ts +17 -3
  26. package/dist/types/components/select/select.d.ts +6 -0
  27. package/dist/types/components/sheet/sheet.d.ts +15 -0
  28. package/dist/types/components/table/table.d.ts +265 -17
  29. package/dist/types/components/view/record-view.d.ts +59 -2
  30. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  31. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  32. package/dist/types/core/component.d.ts +12 -2
  33. package/dist/types/core/i18n.d.ts +27 -3
  34. package/dist/types/core/keyboard.d.ts +98 -1
  35. package/dist/types/core/messages.d.ts +382 -0
  36. package/dist/types/core/overlay-host.d.ts +28 -1
  37. package/dist/types/core/overlay-stack.d.ts +260 -0
  38. package/dist/types/index.d.ts +5 -2
  39. package/dist/types/internal/app-rail.d.ts +38 -0
  40. package/dist/types/internal/menu-items.d.ts +71 -3
  41. package/dist/types/zeyos/index.d.ts +1 -0
  42. package/dist/types/zeyos/launcher.d.ts +9 -0
  43. package/dist/types/zeyos/module-bar.d.ts +176 -0
  44. package/dist/types/zeyos/query.d.ts +31 -5
  45. package/dist/types/zeyos/table.d.ts +245 -8
  46. package/dist/types/zeyos/view.d.ts +16 -2
  47. package/dist/zx-zeyos.esm.js +4422 -430
  48. package/dist/zx-zeyos.esm.min.js +5 -5
  49. package/dist/zx.css +1417 -134
  50. package/dist/zx.esm.js +6986 -830
  51. package/dist/zx.esm.min.js +8 -8
  52. package/dist/zx.global.js +6986 -830
  53. package/dist/zx.global.min.js +8 -8
  54. package/dist/zx.min.css +1 -1
  55. package/docs/api.json +794 -17
  56. package/docs/llms.md +503 -63
  57. package/docs/llms.txt +6 -2
  58. package/package.json +1 -1
  59. package/src/components/account-menu/account-menu.js +12 -0
  60. package/src/components/activity-list/activity-list.js +27 -1
  61. package/src/components/app-icon/app-icon.css +13 -2
  62. package/src/components/app-icon/app-icon.js +11 -1
  63. package/src/components/app-rail/app-rail.css +19 -0
  64. package/src/components/app-sidebar/app-sidebar.css +204 -3
  65. package/src/components/app-sidebar/app-sidebar.js +646 -40
  66. package/src/components/breadcrumb/breadcrumb.css +1 -1
  67. package/src/components/calendar/calendar.css +1 -1
  68. package/src/components/calendar/calendar.js +26 -52
  69. package/src/components/card/card.css +2 -2
  70. package/src/components/card-view/card-view.css +5 -5
  71. package/src/components/card-view/card-view.js +13 -9
  72. package/src/components/card-view/record-card.js +14 -3
  73. package/src/components/code-editor/code-editor.css +1 -1
  74. package/src/components/code-editor/field-adapter.js +9 -2
  75. package/src/components/context-menu/context-menu.js +8 -2
  76. package/src/components/copy/copy-input.css +1 -1
  77. package/src/components/data-filter/data-filter.js +2 -2
  78. package/src/components/date-picker/time-picker.css +2 -2
  79. package/src/components/dock/dock.css +5 -4
  80. package/src/components/dropdown/dropdown.js +157 -72
  81. package/src/components/field/field.js +33 -3
  82. package/src/components/fieldset/external-adapter.js +363 -0
  83. package/src/components/fieldset/fieldset.js +10 -59
  84. package/src/components/file-list/file-list.js +144 -21
  85. package/src/components/filter/filter.css +86 -2
  86. package/src/components/filter/filter.js +300 -8
  87. package/src/components/filter-panel/filter-panel.js +149 -32
  88. package/src/components/finder/finder.css +1 -1
  89. package/src/components/kanban-view/kanban-view.css +3 -3
  90. package/src/components/kanban-view/kanban-view.js +5 -2
  91. package/src/components/launcher/launcher.css +1 -1
  92. package/src/components/launcher/launcher.js +29 -3
  93. package/src/components/list-toolbar/list-toolbar.js +39 -13
  94. package/src/components/master-panel/master-panel.js +0 -1
  95. package/src/components/menu-button/menu-button.css +63 -0
  96. package/src/components/menu-button/menu-button.js +97 -11
  97. package/src/components/message/message.js +60 -20
  98. package/src/components/modal/modal.css +1 -1
  99. package/src/components/modal/modal.js +66 -13
  100. package/src/components/module-bar/module-bar.css +486 -0
  101. package/src/components/module-bar/module-bar.js +1074 -0
  102. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  103. package/src/components/number-field/field-adapter.js +2 -1
  104. package/src/components/pagination/pagination.css +5 -5
  105. package/src/components/pagination/pagination.js +20 -4
  106. package/src/components/panel/panel.css +3 -2
  107. package/src/components/questionnaire/questionnaire.css +4 -4
  108. package/src/components/rating/field-adapter.js +2 -1
  109. package/src/components/rating/rating.css +1 -1
  110. package/src/components/search/search.css +4 -1
  111. package/src/components/select/select.js +9 -0
  112. package/src/components/sheet/sheet.css +1 -1
  113. package/src/components/sheet/sheet.js +74 -32
  114. package/src/components/slider/field-adapter.js +6 -1
  115. package/src/components/slider/slider.css +2 -2
  116. package/src/components/split-view/split-view.css +1 -1
  117. package/src/components/stat-tile/stat-tile.css +1 -1
  118. package/src/components/stepper/stepper.css +1 -1
  119. package/src/components/tabbox/tabbox.css +9 -5
  120. package/src/components/table/table.css +86 -11
  121. package/src/components/table/table.js +628 -57
  122. package/src/components/tag-picker/field-adapter.js +2 -1
  123. package/src/components/tooltip/tooltip.js +35 -17
  124. package/src/components/tree/tree.css +1 -1
  125. package/src/components/view/record-view.css +1 -1
  126. package/src/components/view/record-view.js +178 -18
  127. package/src/components/view-settings/view-settings.css +285 -0
  128. package/src/components/view-settings/view-settings.js +1830 -0
  129. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  130. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  131. package/src/core/component.js +88 -17
  132. package/src/core/fontawesome.js +5 -1
  133. package/src/core/i18n.js +47 -4
  134. package/src/core/icons.js +12 -2
  135. package/src/core/keyboard.js +206 -11
  136. package/src/core/messages.js +427 -0
  137. package/src/core/overlay-host.js +64 -9
  138. package/src/core/overlay-stack.js +487 -0
  139. package/src/core/position.js +22 -3
  140. package/src/index.js +5 -2
  141. package/src/internal/app-rail.js +134 -53
  142. package/src/internal/menu-items.js +61 -3
  143. package/src/zeyos/form.js +5 -3
  144. package/src/zeyos/index.js +3 -0
  145. package/src/zeyos/launcher.js +8 -3
  146. package/src/zeyos/module-bar.js +181 -0
  147. package/src/zeyos/query.js +44 -13
  148. package/src/zeyos/table.js +220 -80
  149. package/src/zeyos/view.js +43 -93
  150. package/styles/base.css +16 -1
  151. package/styles/print.css +1 -1
  152. package/styles/tokens/dark.css +4 -0
  153. package/styles/tokens/semantic.css +56 -28
  154. package/styles/zx.css +3 -0
@@ -147,6 +147,15 @@ import { sortRows } from './sort.js';
147
147
  * @property {string[]} [hiddenColumns=[]] Initially hidden column IDs; at least one stays visible.
148
148
  * @property {false|TableHierarchyOptions} [hierarchy=false] Flat parent references projected as an
149
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`.
150
159
  * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowclick]
151
160
  * @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowdblclick]
152
161
  * @property {(event: CustomEvent<TableSortDetail>) => void} [onsort]
@@ -259,6 +268,20 @@ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1
259
268
  /**
260
269
  * Semantic, sortable data table with optional single or checkbox-based multi-selection and
261
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.
262
285
  * @fires Table#rowclick
263
286
  * @fires Table#rowdblclick
264
287
  * @fires Table#sort
@@ -300,6 +323,9 @@ export class Table extends Component {
300
323
  editMode: false,
301
324
  editTrigger: 'double',
302
325
  hierarchy: false,
326
+ interactive: false,
327
+ label: null,
328
+ labelledBy: null,
303
329
  rowReorder: false,
304
330
  columnVisibility: false,
305
331
  columnReorder: false,
@@ -336,6 +362,7 @@ export class Table extends Component {
336
362
  this._selectionAnchorId = null;
337
363
  this._sort = normalizeSort(this.options.sort, this._columns);
338
364
  this._rowMeta = new WeakMap();
365
+ this._rowNames = new WeakMap();
339
366
  this._rowElements = new Map();
340
367
  this._restored = false;
341
368
  // Editing state. `_edit` is null unless a cell or row is currently open for editing; every
@@ -349,9 +376,17 @@ export class Table extends Component {
349
376
  this._growStep = growStep(this.options.growing);
350
377
  this._rendered = this._growStep ?? Infinity;
351
378
  this._more = null;
352
- this._rovingKey = null;
353
- this._rovingCell = null;
354
- 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;
355
390
  this._hierarchy = normalizeHierarchy(this.options.hierarchy, this._columns);
356
391
  this._hiddenColumns = new Set((Array.isArray(this.options.hiddenColumns) ? this.options.hiddenColumns : [])
357
392
  .map(String).filter((id) => this._columns.some((column) => column.id === id)));
@@ -373,7 +408,7 @@ export class Table extends Component {
373
408
  if (normalizeTableVariant(this.options.variant) === 'plain') root.dataset.variant = 'plain';
374
409
 
375
410
  this._table = h('table', { class: 'zx-table__table' });
376
- if (this._hierarchy) this._table.setAttribute('role', 'treegrid');
411
+ this._syncGridRole();
377
412
  this._colgroup = h('colgroup', { class: 'zx-table__columns' });
378
413
  this._thead = h('thead', { class: 'zx-table__head' });
379
414
  this._tbody = h('tbody', { class: 'zx-table__body' });
@@ -434,9 +469,12 @@ export class Table extends Component {
434
469
  }
435
470
  if (this._editMode !== false) {
436
471
  this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
437
- this.listen(this._tbody, 'focusin', (event) => this._handleEditFocusIn(event));
438
472
  this.listen(document, 'pointerdown', (event) => this._handleDocumentPointerDown(event), { capture: true });
439
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));
440
478
  return root;
441
479
  }
442
480
 
@@ -530,6 +568,7 @@ export class Table extends Component {
530
568
  this._reconcileExpanded();
531
569
  const selectionChanged = this._selected.delete(id);
532
570
  if (Object.is(this._selectionAnchorId, id)) this._selectionAnchorId = null;
571
+ this._extendBase = null;
533
572
  this._renderBody();
534
573
  if (selectionChanged) this._emitSelectionChange();
535
574
  this._emitDataChange();
@@ -743,6 +782,7 @@ export class Table extends Component {
743
782
  this._expanded.clear();
744
783
  this._selected.clear();
745
784
  this._selectionAnchorId = null;
785
+ this._extendBase = null;
746
786
  this._renderBody();
747
787
  if (selectionChanged) this._emitSelectionChange();
748
788
  this._emitDataChange();
@@ -837,6 +877,7 @@ export class Table extends Component {
837
877
  if (this.options.selectable === 'single' && next.size > 0) break;
838
878
  }
839
879
  }
880
+ this._extendBase = null;
840
881
  if (sameSet(this._selected, next)) return this;
841
882
  this._selected = next;
842
883
  this._syncSelection();
@@ -853,6 +894,7 @@ export class Table extends Component {
853
894
  if (this._selected.size === 0) return this;
854
895
  this._selected.clear();
855
896
  this._selectionAnchorId = null;
897
+ this._extendBase = null;
856
898
  this._syncSelection();
857
899
  this._emitSelectionChange();
858
900
  return this;
@@ -1216,25 +1258,67 @@ export class Table extends Component {
1216
1258
  this.emit('stackedchange', { stacked });
1217
1259
  }
1218
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
+
1219
1302
  /**
1220
1303
  * States the table roles explicitly while stacked, and removes them again on the way out.
1221
1304
  *
1222
1305
  * Stacking works by changing `display` on the table elements, and a table whose display is not
1223
1306
  * `table` loses its implicit ARIA roles — leaving a screen reader with a pile of generic blocks
1224
- * 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.
1225
1309
  * @param {boolean} stacked Whether the roles should be present.
1226
1310
  * @returns {void}
1227
1311
  */
1228
1312
  _applyStackedRoles(stacked) {
1229
- /** @type {Array<[Element|null, string]>} */
1230
- const parts = [[this._table, this._hierarchy ? 'treegrid' : 'table'], [this._thead, 'rowgroup'], [this._tbody, 'rowgroup']];
1231
- for (const [element, role] of parts) {
1232
- if (!element) continue;
1233
- if (stacked) element.setAttribute('role', role);
1234
- 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');
1235
1319
  else element.removeAttribute('role');
1236
1320
  }
1237
- 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']]) {
1238
1322
  for (const element of this._table.querySelectorAll(selector)) {
1239
1323
  if (stacked) element.setAttribute('role', role);
1240
1324
  else element.removeAttribute('role');
@@ -1585,7 +1669,7 @@ export class Table extends Component {
1585
1669
  this._selectAll = h('input', {
1586
1670
  class: 'zx-table__checkbox zx-table__select-all',
1587
1671
  type: 'checkbox',
1588
- ariaLabel: 'Select all rows'
1672
+ ariaLabel: this._message('table.selectAll', 'Select all rows', [])
1589
1673
  });
1590
1674
  const selectionHeader = h('th', {
1591
1675
  class: 'zx-table__selection-header',
@@ -1604,6 +1688,7 @@ export class Table extends Component {
1604
1688
  for (const dataRow of this._selectableData()) this._selected.delete(this._idFor(dataRow));
1605
1689
  }
1606
1690
  this._selectionAnchorId = null;
1691
+ this._extendBase = null;
1607
1692
  this._syncSelection();
1608
1693
  this._emitSelectionChange();
1609
1694
  });
@@ -1650,9 +1735,16 @@ export class Table extends Component {
1650
1735
 
1651
1736
  /** @returns {void} @fires Table#editcancel */
1652
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();
1653
1741
  // Every data mutation funnels through here, so this is the single place that guarantees no
1654
1742
  // editor component outlives the rows it was rendered into.
1655
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;
1656
1748
  /*
1657
1749
  * And the single place that notices the data gaining or losing its sections. The header owns
1658
1750
  * the sort controls, so it has to be rebuilt when the answer flips — otherwise a table that was
@@ -1667,9 +1759,9 @@ export class Table extends Component {
1667
1759
  if (this._headers && this._spanning !== this._headerSpanning) this._renderHeader();
1668
1760
  const fragment = document.createDocumentFragment();
1669
1761
  this._rowMeta = new WeakMap();
1762
+ this._rowNames = new WeakMap();
1670
1763
  this._rowElements = new Map();
1671
- this._rovingCell = null;
1672
- this._rovingMatched = false;
1764
+ this._rovingEl = null;
1673
1765
  const projected = this._projectedData();
1674
1766
  this._hierarchyMeta = new Map(projected.map((entry) => [entry.id, entry]));
1675
1767
  if (this._data.length === 0) {
@@ -1693,6 +1785,7 @@ export class Table extends Component {
1693
1785
  this._syncSelectAll();
1694
1786
  this._syncGrowing();
1695
1787
  if (this._stacked) this._applyStackedRoles(true);
1788
+ this._syncRoving(focus);
1696
1789
  }
1697
1790
 
1698
1791
  /**
@@ -1737,6 +1830,9 @@ export class Table extends Component {
1737
1830
  tr.setAttribute('aria-level', String(tree.depth + 1));
1738
1831
  if (tree.hasChildren) tr.setAttribute('aria-expanded', String(this._expanded.has(id)));
1739
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;
1740
1836
  this._rowMeta.set(tr, { row, id, index, visibleIndex: tree.visibleIndex });
1741
1837
  const elements = this._rowElements.get(id) ?? [];
1742
1838
  elements.push(tr);
@@ -1757,6 +1853,7 @@ export class Table extends Component {
1757
1853
  if (content && typeof content === 'object' && typeof content.nodeType === 'number') {
1758
1854
  cell.append(content);
1759
1855
  } else if (content != null) cell.append(document.createTextNode(String(content)));
1856
+ if (this._focusMode === 'cell') cell.tabIndex = -1;
1760
1857
  tr.append(cell);
1761
1858
  return tr;
1762
1859
  }
@@ -1764,7 +1861,7 @@ export class Table extends Component {
1764
1861
  if (this.options.rowReorder) {
1765
1862
  const handle = h('button', {
1766
1863
  class: 'zx-table__row-handle', type: 'button', draggable: true,
1767
- dataset: { rowHandle: '' }, ariaLabel: `Move row ${index + 1}`,
1864
+ dataset: { rowHandle: '' },
1768
1865
  ariaPressed: String(Object.is(this._grabbedId, id)),
1769
1866
  disabled: Boolean(this._sort && this.options.sortMode === 'local'),
1770
1867
  title: this._sort && this.options.sortMode === 'local' ? 'Clear sorting to reorder rows' : null
@@ -1773,11 +1870,13 @@ export class Table extends Component {
1773
1870
  }
1774
1871
 
1775
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.
1776
1875
  const checkbox = h('input', {
1777
1876
  class: 'zx-table__checkbox zx-table__row-checkbox',
1778
1877
  type: 'checkbox',
1779
- checked: this._selected.has(id),
1780
- ariaLabel: `Select row ${index + 1}`
1878
+ tabIndex: -1,
1879
+ checked: this._selected.has(id)
1781
1880
  });
1782
1881
  tr.append(h('td', { class: 'zx-table__selection-cell' }, checkbox));
1783
1882
  }
@@ -1791,19 +1890,40 @@ export class Table extends Component {
1791
1890
  cell.dataset.label = column.label;
1792
1891
  cell.dataset.column = column.id;
1793
1892
  if (column.popin === false) cell.dataset.popin = 'false';
1794
- if (this._editMode !== false) {
1795
- if (resolveEditable(column, row) !== false) {
1796
- cell.dataset.editable = 'true';
1797
- cell.tabIndex = -1;
1798
- this._offerRovingCell(cell, id, column.id);
1799
- }
1800
- }
1893
+ if (this._focusMode === 'cell') cell.tabIndex = -1;
1894
+ if (this._editMode !== false && resolveEditable(column, row) !== false) cell.dataset.editable = 'true';
1801
1895
  this._renderCellContent(cell, column, row, index, tree);
1802
1896
  tr.append(cell);
1803
1897
  });
1898
+ this._rowNames.set(tr, tableRowLabel([...tr.cells].map((cell) => cell.textContent)));
1899
+ this._labelRowControls(tr);
1804
1900
  return tr;
1805
1901
  }
1806
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
+
1807
1927
  /**
1808
1928
  * Fills a cell with the column's rendered value, replacing anything already in it.
1809
1929
  * @param {HTMLTableCellElement} cell Target cell.
@@ -1828,17 +1948,21 @@ export class Table extends Component {
1828
1948
  branch.style.setProperty('--zx-table-depth', String(tree.depth));
1829
1949
  if (tree.hasChildren) {
1830
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.
1831
1953
  branch.append(h('button', {
1832
1954
  class: 'zx-table__tree-toggle',
1833
1955
  type: 'button',
1834
- ariaExpanded: String(expanded),
1835
- ariaLabel: `${expanded ? 'Collapse' : 'Expand'} row ${index + 1}`
1956
+ tabIndex: -1,
1957
+ ariaExpanded: String(expanded)
1836
1958
  }, icon(expanded ? 'chevron-down' : 'chevron-right', { size: 14 })));
1837
1959
  } else {
1838
1960
  branch.append(h('span', { class: 'zx-table__tree-spacer', ariaHidden: 'true' }));
1839
1961
  }
1840
1962
  branch.append(content);
1841
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));
1842
1966
  }
1843
1967
 
1844
1968
  /** @param {MouseEvent} event @returns {void} */
@@ -1882,6 +2006,18 @@ export class Table extends Component {
1882
2006
  }
1883
2007
  }
1884
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) {
1885
2021
  this.emit('rowclick', { ...meta, event });
1886
2022
  // The click still reaches the host — collapsing a section is a reasonable thing to hang off it —
1887
2023
  // but it does not select the heading, which carries no checkbox in `multi` either.
@@ -1889,6 +2025,7 @@ export class Table extends Component {
1889
2025
  && !this._isSpanningRow(meta.row)) {
1890
2026
  this._selected = new Set([meta.id]);
1891
2027
  this._selectionAnchorId = meta.id;
2028
+ this._extendBase = null;
1892
2029
  this._syncSelection();
1893
2030
  this._emitSelectionChange();
1894
2031
  }
@@ -2265,16 +2402,6 @@ export class Table extends Component {
2265
2402
  return null;
2266
2403
  }
2267
2404
 
2268
- /** @param {Event} event @returns {void} */
2269
- _handleEditFocusIn(event) {
2270
- const target = /** @type {Element|null} */ (event.target?.nodeType === 1 ? event.target : null);
2271
- const cell = target?.closest('td[data-editable="true"]');
2272
- if (!cell || !this._tbody.contains(cell)) return;
2273
- const meta = this._rowMeta.get(cell.parentElement);
2274
- if (!meta) return;
2275
- this._setRovingCell(/** @type {HTMLTableCellElement} */ (cell), meta.id, String(cell.dataset.column));
2276
- }
2277
-
2278
2405
  /** @param {Event} event @returns {void} @fires Table#editcommit */
2279
2406
  _handleDocumentPointerDown(event) {
2280
2407
  if (!this._edit) return;
@@ -2286,31 +2413,366 @@ export class Table extends Component {
2286
2413
  this.commitEdit();
2287
2414
  }
2288
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
+
2289
2422
  /**
2290
- * Keeps exactly one editable cell in the tab order, preferring the last focused one.
2291
- * @param {HTMLTableCellElement} cell Newly rendered editable cell.
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
+
2517
+ /**
2518
+ * The nearest ancestor of a row that is rendered — where focus goes when collapsing a branch
2519
+ * hid the focused row.
2292
2520
  * @param {unknown} id Row id.
2293
- * @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.
2294
2542
  * @returns {void}
2295
2543
  */
2296
- _offerRovingCell(cell, id, columnId) {
2297
- if (this._rovingMatched) return;
2298
- const preferred = this._rovingKey !== null
2299
- && Object.is(this._rovingKey.id, id) && this._rovingKey.columnId === columnId;
2300
- if (!preferred && this._rovingCell) return;
2301
- if (preferred && this._rovingCell) this._rovingCell.tabIndex = -1;
2302
- cell.tabIndex = 0;
2303
- this._rovingCell = cell;
2304
- this._rovingMatched = preferred;
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
+ }
2305
2556
  }
2306
2557
 
2307
- /** @param {HTMLTableCellElement} cell @param {unknown} id @param {string} columnId @returns {void} */
2308
- _setRovingCell(cell, id, columnId) {
2309
- if (this._rovingCell && this._rovingCell !== cell) this._rovingCell.tabIndex = -1;
2310
- cell.tabIndex = 0;
2311
- this._rovingCell = cell;
2312
- this._rovingMatched = true;
2313
- this._rovingKey = { id, columnId };
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.
2688
+ * @returns {void}
2689
+ * @fires Table#rowtoggle
2690
+ */
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();
2747
+ }
2748
+
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();
2314
2776
  }
2315
2777
 
2316
2778
  /**
@@ -2346,6 +2808,7 @@ export class Table extends Component {
2346
2808
  this._selected.delete(meta.id);
2347
2809
  }
2348
2810
  this._selectionAnchorId = meta.id;
2811
+ this._extendBase = null;
2349
2812
  this._syncSelection();
2350
2813
  this._emitSelectionChange();
2351
2814
  }
@@ -2452,6 +2915,7 @@ export class Table extends Component {
2452
2915
  const previousSize = this._selected.size;
2453
2916
  for (const id of this._selected) if (!valid.has(id)) this._selected.delete(id);
2454
2917
  if (!valid.has(this._selectionAnchorId)) this._selectionAnchorId = null;
2918
+ this._extendBase = null;
2455
2919
  return previousSize !== this._selected.size;
2456
2920
  }
2457
2921
 
@@ -2520,6 +2984,113 @@ export class Table extends Component {
2520
2984
  * @type {CustomEvent<TableEditInvalidDetail>}
2521
2985
  */
2522
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
+
2523
3094
  /**
2524
3095
  * Resolves a row's kind through the `rowKind` accessor and coerces it to the string `rowKinds` is
2525
3096
  * keyed by.