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