@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.
- package/CHANGELOG.md +164 -0
- package/README.md +2 -1
- package/dist/types/components/account-menu/account-menu.d.ts +8 -0
- package/dist/types/components/activity-list/activity-list.d.ts +9 -1
- package/dist/types/components/app-icon/app-icon.d.ts +14 -2
- package/dist/types/components/app-sidebar/app-sidebar.d.ts +288 -14
- package/dist/types/components/calendar/calendar.d.ts +67 -5
- package/dist/types/components/card-view/card-view.d.ts +7 -4
- package/dist/types/components/card-view/record-card.d.ts +11 -0
- package/dist/types/components/context-menu/context-menu.d.ts +6 -2
- package/dist/types/components/data-filter/data-filter.d.ts +4 -4
- package/dist/types/components/dropdown/dropdown.d.ts +22 -7
- package/dist/types/components/field/field.d.ts +30 -0
- package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
- package/dist/types/components/fieldset/fieldset.d.ts +7 -0
- package/dist/types/components/file-list/file-list.d.ts +35 -3
- package/dist/types/components/filter/filter.d.ts +127 -3
- package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
- package/dist/types/components/launcher/launcher.d.ts +8 -0
- package/dist/types/components/list-toolbar/list-toolbar.d.ts +15 -6
- package/dist/types/components/menu-button/menu-button.d.ts +6 -0
- package/dist/types/components/message/message.d.ts +14 -1
- package/dist/types/components/modal/modal.d.ts +30 -0
- package/dist/types/components/module-bar/module-bar.d.ts +395 -0
- package/dist/types/components/pagination/pagination.d.ts +17 -3
- package/dist/types/components/select/select.d.ts +6 -0
- package/dist/types/components/sheet/sheet.d.ts +15 -0
- package/dist/types/components/table/table.d.ts +265 -17
- package/dist/types/components/view/record-view.d.ts +59 -2
- package/dist/types/components/view-settings/view-settings.d.ts +879 -0
- package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
- package/dist/types/core/component.d.ts +12 -2
- package/dist/types/core/i18n.d.ts +27 -3
- package/dist/types/core/keyboard.d.ts +98 -1
- package/dist/types/core/messages.d.ts +382 -0
- package/dist/types/core/overlay-host.d.ts +28 -1
- package/dist/types/core/overlay-stack.d.ts +260 -0
- package/dist/types/index.d.ts +5 -2
- package/dist/types/internal/app-rail.d.ts +38 -0
- package/dist/types/internal/menu-items.d.ts +71 -3
- package/dist/types/zeyos/index.d.ts +1 -0
- package/dist/types/zeyos/launcher.d.ts +9 -0
- package/dist/types/zeyos/module-bar.d.ts +176 -0
- package/dist/types/zeyos/query.d.ts +31 -5
- package/dist/types/zeyos/table.d.ts +245 -8
- package/dist/types/zeyos/view.d.ts +16 -2
- package/dist/zx-zeyos.esm.js +4422 -430
- package/dist/zx-zeyos.esm.min.js +5 -5
- package/dist/zx.css +1417 -134
- package/dist/zx.esm.js +6986 -830
- package/dist/zx.esm.min.js +8 -8
- package/dist/zx.global.js +6986 -830
- package/dist/zx.global.min.js +8 -8
- package/dist/zx.min.css +1 -1
- package/docs/api.json +794 -17
- package/docs/llms.md +503 -63
- package/docs/llms.txt +6 -2
- package/package.json +1 -1
- package/src/components/account-menu/account-menu.js +12 -0
- package/src/components/activity-list/activity-list.js +27 -1
- package/src/components/app-icon/app-icon.css +13 -2
- package/src/components/app-icon/app-icon.js +11 -1
- package/src/components/app-rail/app-rail.css +19 -0
- package/src/components/app-sidebar/app-sidebar.css +204 -3
- package/src/components/app-sidebar/app-sidebar.js +646 -40
- package/src/components/breadcrumb/breadcrumb.css +1 -1
- package/src/components/calendar/calendar.css +1 -1
- package/src/components/calendar/calendar.js +26 -52
- package/src/components/card/card.css +2 -2
- package/src/components/card-view/card-view.css +5 -5
- package/src/components/card-view/card-view.js +13 -9
- package/src/components/card-view/record-card.js +14 -3
- package/src/components/code-editor/code-editor.css +1 -1
- package/src/components/code-editor/field-adapter.js +9 -2
- package/src/components/context-menu/context-menu.js +8 -2
- package/src/components/copy/copy-input.css +1 -1
- package/src/components/data-filter/data-filter.js +2 -2
- package/src/components/date-picker/time-picker.css +2 -2
- package/src/components/dock/dock.css +5 -4
- package/src/components/dropdown/dropdown.js +157 -72
- package/src/components/field/field.js +33 -3
- package/src/components/fieldset/external-adapter.js +363 -0
- package/src/components/fieldset/fieldset.js +10 -59
- package/src/components/file-list/file-list.js +144 -21
- package/src/components/filter/filter.css +86 -2
- package/src/components/filter/filter.js +300 -8
- package/src/components/filter-panel/filter-panel.js +149 -32
- package/src/components/finder/finder.css +1 -1
- package/src/components/kanban-view/kanban-view.css +3 -3
- package/src/components/kanban-view/kanban-view.js +5 -2
- package/src/components/launcher/launcher.css +1 -1
- package/src/components/launcher/launcher.js +29 -3
- package/src/components/list-toolbar/list-toolbar.js +39 -13
- package/src/components/master-panel/master-panel.js +0 -1
- package/src/components/menu-button/menu-button.css +63 -0
- package/src/components/menu-button/menu-button.js +97 -11
- package/src/components/message/message.js +60 -20
- package/src/components/modal/modal.css +1 -1
- package/src/components/modal/modal.js +66 -13
- package/src/components/module-bar/module-bar.css +486 -0
- package/src/components/module-bar/module-bar.js +1074 -0
- package/src/components/multi-value-editor/multi-value-editor.css +1 -1
- package/src/components/number-field/field-adapter.js +2 -1
- package/src/components/pagination/pagination.css +5 -5
- package/src/components/pagination/pagination.js +20 -4
- package/src/components/panel/panel.css +3 -2
- package/src/components/questionnaire/questionnaire.css +4 -4
- package/src/components/rating/field-adapter.js +2 -1
- package/src/components/rating/rating.css +1 -1
- package/src/components/search/search.css +4 -1
- package/src/components/select/select.js +9 -0
- package/src/components/sheet/sheet.css +1 -1
- package/src/components/sheet/sheet.js +74 -32
- package/src/components/slider/field-adapter.js +6 -1
- package/src/components/slider/slider.css +2 -2
- package/src/components/split-view/split-view.css +1 -1
- package/src/components/stat-tile/stat-tile.css +1 -1
- package/src/components/stepper/stepper.css +1 -1
- package/src/components/tabbox/tabbox.css +9 -5
- package/src/components/table/table.css +86 -11
- package/src/components/table/table.js +628 -57
- package/src/components/tag-picker/field-adapter.js +2 -1
- package/src/components/tooltip/tooltip.js +35 -17
- package/src/components/tree/tree.css +1 -1
- package/src/components/view/record-view.css +1 -1
- package/src/components/view/record-view.js +178 -18
- package/src/components/view-settings/view-settings.css +285 -0
- package/src/components/view-settings/view-settings.js +1830 -0
- package/src/components/workspace-switcher/workspace-switcher.css +188 -0
- package/src/components/workspace-switcher/workspace-switcher.js +463 -0
- package/src/core/component.js +88 -17
- package/src/core/fontawesome.js +5 -1
- package/src/core/i18n.js +47 -4
- package/src/core/icons.js +12 -2
- package/src/core/keyboard.js +206 -11
- package/src/core/messages.js +427 -0
- package/src/core/overlay-host.js +64 -9
- package/src/core/overlay-stack.js +487 -0
- package/src/core/position.js +22 -3
- package/src/index.js +5 -2
- package/src/internal/app-rail.js +134 -53
- package/src/internal/menu-items.js +61 -3
- package/src/zeyos/form.js +5 -3
- package/src/zeyos/index.js +3 -0
- package/src/zeyos/launcher.js +8 -3
- package/src/zeyos/module-bar.js +181 -0
- package/src/zeyos/query.js +44 -13
- package/src/zeyos/table.js +220 -80
- package/src/zeyos/view.js +43 -93
- package/styles/base.css +16 -1
- package/styles/print.css +1 -1
- package/styles/tokens/dark.css +4 -0
- package/styles/tokens/semantic.css +56 -28
- 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
|
-
|
|
353
|
-
|
|
354
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
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.
|
|
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: '' },
|
|
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
|
-
|
|
1780
|
-
|
|
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.
|
|
1795
|
-
|
|
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
|
-
|
|
1835
|
-
|
|
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
|
-
*
|
|
2291
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
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 {
|
|
2308
|
-
|
|
2309
|
-
if (this.
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
this.
|
|
2313
|
-
|
|
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.
|