@zeyos/zx 4.0.0 → 4.1.1
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 +61 -0
- package/DESIGN-SYSTEM.md +59 -3
- package/dist/types/components/app-icon/app-icon.d.ts +88 -0
- package/dist/types/components/filter/filter-model.d.ts +87 -0
- package/dist/types/components/filter/filter.d.ts +331 -0
- package/dist/types/components/select/select.d.ts +46 -4
- package/dist/types/components/table/table.d.ts +103 -0
- package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
- package/dist/types/core/dom.d.ts +1 -1
- package/dist/types/core/util.d.ts +9 -0
- package/dist/types/index.d.ts +4 -1
- package/dist/types/zeyos/icons.d.ts +21 -4
- package/dist/types/zeyos/index.d.ts +1 -1
- package/dist/types/zeyos/select.d.ts +2 -0
- package/dist/zx-zeyos.esm.js +703 -75
- package/dist/zx-zeyos.esm.min.js +1 -1
- package/dist/zx.css +564 -9
- package/dist/zx.esm.js +1569 -115
- package/dist/zx.esm.min.js +3 -3
- package/dist/zx.global.js +1569 -115
- package/dist/zx.global.min.js +3 -3
- package/dist/zx.min.css +1 -1
- package/docs/api.json +379 -0
- package/docs/llms.md +97 -18
- package/docs/llms.txt +11 -9
- package/package.json +1 -1
- package/src/components/app-icon/app-icon.css +189 -0
- package/src/components/app-icon/app-icon.js +137 -0
- package/src/components/button/button.css +34 -2
- package/src/components/card/card.css +25 -3
- package/src/components/filter/filter-model.js +226 -0
- package/src/components/filter/filter.css +147 -0
- package/src/components/filter/filter.js +554 -0
- package/src/components/grid/grid.css +1 -1
- package/src/components/grid/grid.js +4 -1
- package/src/components/message/message.css +40 -3
- package/src/components/select/select.css +17 -0
- package/src/components/select/select.js +105 -24
- package/src/components/table/table.css +85 -0
- package/src/components/table/table.js +418 -24
- package/src/components/tag-picker/tag-picker.css +19 -0
- package/src/components/tag-picker/tag-picker.js +42 -5
- package/src/core/dom.js +1 -1
- package/src/core/util.js +20 -0
- package/src/index.js +7 -1
- package/src/zeyos/icons.js +37 -24
- package/src/zeyos/index.js +1 -1
- package/src/zeyos/select.js +6 -5
- package/styles/tokens/semantic.css +17 -0
- package/styles/tokens/themes.css +2 -2
- package/styles/zx.css +2 -0
|
@@ -104,6 +104,10 @@ import { sortRows } from './sort.js';
|
|
|
104
104
|
* @property {TableEditMode} [editMode=false] Inline editing mode. `'cell'` edits one cell, `'row'`
|
|
105
105
|
* opens every editable cell of the row together and commits them as a unit. Editing is completely
|
|
106
106
|
* inert while this is `false`.
|
|
107
|
+
* @property {'single'|'double'} [editTrigger='double'] Gesture that starts an editable cell.
|
|
108
|
+
* @property {boolean} [rowReorder=false] Whether rows expose pointer/keyboard reorder handles.
|
|
109
|
+
* @property {boolean} [columnVisibility=false] Whether a column visibility chooser is rendered.
|
|
110
|
+
* @property {string[]} [hiddenColumns=[]] Initially hidden column IDs; at least one stays visible.
|
|
107
111
|
* @property {false|TableHierarchyOptions} [hierarchy=false] Flat parent references projected as an
|
|
108
112
|
* expandable treegrid. Row data and mutation APIs remain flat.
|
|
109
113
|
* @property {(event: CustomEvent<TableRowClickDetail>) => void} [onrowclick]
|
|
@@ -120,6 +124,8 @@ import { sortRows } from './sort.js';
|
|
|
120
124
|
* @property {(event: CustomEvent<TableEditCancelDetail>) => void} [oneditcancel]
|
|
121
125
|
* @property {(event: CustomEvent<TableEditInvalidDetail>) => void} [oneditinvalid]
|
|
122
126
|
* @property {(event: CustomEvent<TableRowToggleDetail>) => void} [onrowtoggle]
|
|
127
|
+
* @property {(event: CustomEvent<TableRowMoveDetail>) => void} [onrowmove]
|
|
128
|
+
* @property {(event: CustomEvent<{visible:string[],hidden:string[]}>) => void} [oncolumnvisibilitychange]
|
|
123
129
|
*/
|
|
124
130
|
|
|
125
131
|
/**
|
|
@@ -134,6 +140,7 @@ import { sortRows } from './sort.js';
|
|
|
134
140
|
/** @typedef {{rows: TableRow[], ids: unknown[]}} TableSelectionDetail */
|
|
135
141
|
/** @typedef {{rows: TableRow[]}} TableDataDetail */
|
|
136
142
|
/** @typedef {{row: TableRow, id: unknown, expanded: boolean}} TableRowToggleDetail */
|
|
143
|
+
/** @typedef {{row:TableRow,id:unknown,target:TableRow,targetId:unknown,position:'before'|'after'}} TableRowMoveDetail */
|
|
137
144
|
|
|
138
145
|
/**
|
|
139
146
|
* Visible hierarchy metadata returned by `projectHierarchyRows`.
|
|
@@ -226,6 +233,8 @@ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1
|
|
|
226
233
|
* @fires Table#rowtoggle
|
|
227
234
|
* @fires Table#stackedchange
|
|
228
235
|
* @fires Table#grow
|
|
236
|
+
* @fires Table#rowmove
|
|
237
|
+
* @fires Table#columnvisibilitychange
|
|
229
238
|
* @extends {Component<TableOptions>}
|
|
230
239
|
*/
|
|
231
240
|
export class Table extends Component {
|
|
@@ -247,7 +256,11 @@ export class Table extends Component {
|
|
|
247
256
|
rowClass: null,
|
|
248
257
|
zebra: true,
|
|
249
258
|
editMode: false,
|
|
250
|
-
|
|
259
|
+
editTrigger: 'double',
|
|
260
|
+
hierarchy: false,
|
|
261
|
+
rowReorder: false,
|
|
262
|
+
columnVisibility: false,
|
|
263
|
+
hiddenColumns: []
|
|
251
264
|
};
|
|
252
265
|
|
|
253
266
|
/**
|
|
@@ -279,6 +292,7 @@ export class Table extends Component {
|
|
|
279
292
|
// Editing state. `_edit` is null unless a cell or row is currently open for editing; every
|
|
280
293
|
// other member below is only ever touched while `_editMode !== false`.
|
|
281
294
|
this._editMode = normalizeEditMode(this.options.editMode);
|
|
295
|
+
this._editTrigger = this.options.editTrigger === 'single' ? 'single' : 'double';
|
|
282
296
|
this._edit = null;
|
|
283
297
|
this._editMessage = null;
|
|
284
298
|
this._stacked = false;
|
|
@@ -290,6 +304,11 @@ export class Table extends Component {
|
|
|
290
304
|
this._rovingCell = null;
|
|
291
305
|
this._rovingMatched = false;
|
|
292
306
|
this._hierarchy = normalizeHierarchy(this.options.hierarchy, this._columns);
|
|
307
|
+
this._hiddenColumns = new Set((Array.isArray(this.options.hiddenColumns) ? this.options.hiddenColumns : [])
|
|
308
|
+
.map(String).filter((id) => this._columns.some((column) => column.id === id)));
|
|
309
|
+
if (this._hiddenColumns.size >= this._columns.length && this._columns[0]) this._hiddenColumns.delete(this._columns[0].id);
|
|
310
|
+
this._dragId = null;
|
|
311
|
+
this._grabbedId = null;
|
|
293
312
|
this._expanded = initialExpanded(this._hierarchy?.expanded);
|
|
294
313
|
|
|
295
314
|
if (this._sort && this.options.sortMode === 'local') this._sortData();
|
|
@@ -314,12 +333,16 @@ export class Table extends Component {
|
|
|
314
333
|
zebra: String(Boolean(this.options.zebra))
|
|
315
334
|
}
|
|
316
335
|
}, this._table);
|
|
336
|
+
this._rowMoveMessage = this.options.rowReorder ? h('div', {
|
|
337
|
+
class: 'zx-table__reorder-status', role: 'status', ariaLive: 'polite'
|
|
338
|
+
}) : null;
|
|
317
339
|
if (this.options.height != null) {
|
|
318
340
|
this._scroll.style.blockSize = typeof this.options.height === 'number' ?
|
|
319
341
|
`${this.options.height}px` : String(this.options.height);
|
|
320
342
|
}
|
|
343
|
+
this._columnControls = this.options.columnVisibility ? this._createColumnControls() : null;
|
|
321
344
|
if (this._editMode === false) {
|
|
322
|
-
root.replaceChildren(this._scroll);
|
|
345
|
+
root.replaceChildren(...[this._columnControls, this._scroll, this._rowMoveMessage].filter(Boolean));
|
|
323
346
|
} else {
|
|
324
347
|
root.dataset.editMode = this._editMode;
|
|
325
348
|
// Always present and empty rather than hidden: a live region that is revealed together with
|
|
@@ -330,7 +353,7 @@ export class Table extends Component {
|
|
|
330
353
|
role: 'status',
|
|
331
354
|
ariaLive: 'polite'
|
|
332
355
|
});
|
|
333
|
-
root.replaceChildren(this._scroll, this._editMessage);
|
|
356
|
+
root.replaceChildren(...[this._columnControls, this._scroll, this._editMessage, this._rowMoveMessage].filter(Boolean));
|
|
334
357
|
}
|
|
335
358
|
|
|
336
359
|
this._renderColumns();
|
|
@@ -339,6 +362,19 @@ export class Table extends Component {
|
|
|
339
362
|
this._watchWidth();
|
|
340
363
|
this.listen(this._tbody, 'click', (event) => this._handleBodyClick(event));
|
|
341
364
|
this.listen(this._tbody, 'dblclick', (event) => this._handleBodyDoubleClick(event));
|
|
365
|
+
if (this.options.rowReorder) {
|
|
366
|
+
this.listen(this._tbody, 'dragstart', (event) => this._handleRowDragStart(/** @type {DragEvent} */ (event)));
|
|
367
|
+
this.listen(this._tbody, 'dragover', (event) => this._handleRowDragOver(/** @type {DragEvent} */ (event)));
|
|
368
|
+
this.listen(this._tbody, 'drop', (event) => this._handleRowDrop(/** @type {DragEvent} */ (event)));
|
|
369
|
+
this.listen(this._tbody, 'dragend', () => this._clearRowDrag());
|
|
370
|
+
this.listen(this._tbody, 'keydown', (event) => this._handleRowHandleKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
371
|
+
}
|
|
372
|
+
if (this._columnControls) {
|
|
373
|
+
this.listen(this._columnControls, 'change', (event) => {
|
|
374
|
+
const input = /** @type {HTMLInputElement|null} */ (event.target);
|
|
375
|
+
if (input?.classList.contains('zx-table__column-toggle')) this.setColumnVisible(input.value, input.checked);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
342
378
|
if (this._editMode !== false) {
|
|
343
379
|
this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
|
|
344
380
|
this.listen(this._tbody, 'focusin', (event) => this._handleEditFocusIn(event));
|
|
@@ -460,6 +496,80 @@ export class Table extends Component {
|
|
|
460
496
|
return [...this._data];
|
|
461
497
|
}
|
|
462
498
|
|
|
499
|
+
/** Returns visible column IDs in caller order. @returns {string[]} */
|
|
500
|
+
getVisibleColumns() {
|
|
501
|
+
return this._visibleColumns().map((column) => column.id);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** Returns hidden column IDs in caller order. @returns {string[]} */
|
|
505
|
+
getHiddenColumns() {
|
|
506
|
+
return this._columns.filter((column) => this._hiddenColumns.has(column.id)).map((column) => column.id);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Shows or hides one column. The final visible column cannot be hidden.
|
|
511
|
+
* @param {string} id Column ID.
|
|
512
|
+
* @param {boolean} [visible=true] Desired visibility.
|
|
513
|
+
* @param {{silent?:boolean}} [options={}] Event behavior.
|
|
514
|
+
* @returns {this}
|
|
515
|
+
*/
|
|
516
|
+
setColumnVisible(id, visible = true, options = {}) {
|
|
517
|
+
const column = this._columns.find((candidate) => candidate.id === id);
|
|
518
|
+
if (!column) throw new RangeError(`Unknown table column: ${id}`);
|
|
519
|
+
const active = /** @type {HTMLElement|null} */ (document.activeElement);
|
|
520
|
+
const restoreFocus = active?.classList.contains('zx-table__column-toggle')
|
|
521
|
+
&& this._columnList?.contains(active) ? /** @type {HTMLInputElement} */ (active).value : null;
|
|
522
|
+
if (!visible && !this._hiddenColumns.has(id) && this._visibleColumns().length <= 1) {
|
|
523
|
+
this._renderColumnControls(restoreFocus);
|
|
524
|
+
return this;
|
|
525
|
+
}
|
|
526
|
+
const changed = visible ? this._hiddenColumns.delete(id) : !this._hiddenColumns.has(id);
|
|
527
|
+
if (!visible && changed) this._hiddenColumns.add(id);
|
|
528
|
+
if (!changed) return this;
|
|
529
|
+
this._renderColumns();
|
|
530
|
+
this._renderHeader();
|
|
531
|
+
this._renderBody();
|
|
532
|
+
this._renderColumnControls(restoreFocus);
|
|
533
|
+
if (!options.silent) this.emit('columnvisibilitychange', {
|
|
534
|
+
visible: this.getVisibleColumns(), hidden: this.getHiddenColumns()
|
|
535
|
+
});
|
|
536
|
+
return this;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/** Toggles one column. @param {string} id @param {{silent?:boolean}} [options={}] @returns {this} */
|
|
540
|
+
toggleColumn(id, options = {}) {
|
|
541
|
+
return this.setColumnVisible(id, this._hiddenColumns.has(id), options);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Reorders one row relative to another and emits a cancelable `rowmove` before committing.
|
|
546
|
+
* Hierarchical rows may only move among siblings; an active local sort disables manual order.
|
|
547
|
+
* @param {unknown} id Moving row ID.
|
|
548
|
+
* @param {unknown} targetId Target row ID.
|
|
549
|
+
* @param {'before'|'after'} [position='before'] Target edge.
|
|
550
|
+
* @returns {this}
|
|
551
|
+
*/
|
|
552
|
+
moveRow(id, targetId, position = 'before') {
|
|
553
|
+
if (!this.options.rowReorder || this._sort && this.options.sortMode === 'local') return this;
|
|
554
|
+
const row = this.getRow(id);
|
|
555
|
+
const target = this.getRow(targetId);
|
|
556
|
+
if (!row || !target || Object.is(id, targetId)) return this;
|
|
557
|
+
const next = reorderTableRows(this._data, this.options.rowId, id, targetId, position, this._hierarchy?.parentId);
|
|
558
|
+
if (!next) return this;
|
|
559
|
+
const event = this.emit('rowmove', { row, id, target, targetId, position });
|
|
560
|
+
if (event.defaultPrevented) return this;
|
|
561
|
+
this._data = next;
|
|
562
|
+
this._renderBody();
|
|
563
|
+
if (this._rowMoveMessage) {
|
|
564
|
+
const siblings = this._siblingIds(id);
|
|
565
|
+
const index = siblings.findIndex((candidate) => Object.is(candidate, id));
|
|
566
|
+
this._rowMoveMessage.textContent = `Moved row to position ${index + 1} of ${siblings.length}.`;
|
|
567
|
+
}
|
|
568
|
+
this._emitDataChange();
|
|
569
|
+
queueMicrotask(() => this._rowHandle(id)?.focus());
|
|
570
|
+
return this;
|
|
571
|
+
}
|
|
572
|
+
|
|
463
573
|
/**
|
|
464
574
|
* Removes all rows.
|
|
465
575
|
* @returns {this}
|
|
@@ -641,15 +751,20 @@ export class Table extends Component {
|
|
|
641
751
|
*/
|
|
642
752
|
startEdit(rowId, columnId) {
|
|
643
753
|
if (this._editMode === false) return this;
|
|
644
|
-
const
|
|
645
|
-
if (!
|
|
754
|
+
const requested = this._columns.find((candidate) => candidate.id === columnId);
|
|
755
|
+
if (!requested) return this;
|
|
646
756
|
const current = this._data.find((candidate) => Object.is(this._idFor(candidate), rowId));
|
|
647
|
-
if (current === undefined
|
|
757
|
+
if (current === undefined) return this;
|
|
758
|
+
const column = this._editMode === 'row' && this._hiddenColumns.has(requested.id)
|
|
759
|
+
? this._visibleColumns().find((candidate) => resolveEditable(candidate, current) !== false) ?? null
|
|
760
|
+
: requested;
|
|
761
|
+
if (!column || resolveEditable(column, current) === false) return this;
|
|
762
|
+
const activeColumnId = column.id;
|
|
648
763
|
|
|
649
764
|
if (this._edit) {
|
|
650
|
-
if (Object.is(this._edit.id, rowId) && this._edit.cells.has(
|
|
651
|
-
this._edit.columnId =
|
|
652
|
-
this._focusEditor(
|
|
765
|
+
if (Object.is(this._edit.id, rowId) && this._edit.cells.has(activeColumnId)) {
|
|
766
|
+
this._edit.columnId = activeColumnId;
|
|
767
|
+
this._focusEditor(activeColumnId);
|
|
653
768
|
return this;
|
|
654
769
|
}
|
|
655
770
|
this.commitEdit();
|
|
@@ -664,7 +779,7 @@ export class Table extends Component {
|
|
|
664
779
|
const tr = this._rowElements.get(rowId)?.[0];
|
|
665
780
|
if (!tr) return this;
|
|
666
781
|
const targets = this._editMode === 'row'
|
|
667
|
-
? this.
|
|
782
|
+
? this._visibleColumns().filter((candidate) => resolveEditable(candidate, row) !== false)
|
|
668
783
|
: [column];
|
|
669
784
|
/** @type {Map<string, TableEditorEntry>} */
|
|
670
785
|
const cells = new Map();
|
|
@@ -676,10 +791,10 @@ export class Table extends Component {
|
|
|
676
791
|
}
|
|
677
792
|
if (cells.size === 0) return this;
|
|
678
793
|
|
|
679
|
-
this._edit = { id: rowId, columnId, row, cells, tr };
|
|
794
|
+
this._edit = { id: rowId, columnId: activeColumnId, row, cells, tr };
|
|
680
795
|
tr.dataset.editing = 'true';
|
|
681
|
-
this.emit('editstart', { row, id: rowId, column, columnId, value: row?.[
|
|
682
|
-
this._focusEditor(
|
|
796
|
+
this.emit('editstart', { row, id: rowId, column, columnId: activeColumnId, value: row?.[activeColumnId] });
|
|
797
|
+
this._focusEditor(activeColumnId);
|
|
683
798
|
return this;
|
|
684
799
|
}
|
|
685
800
|
|
|
@@ -951,16 +1066,185 @@ export class Table extends Component {
|
|
|
951
1066
|
super.destroy();
|
|
952
1067
|
}
|
|
953
1068
|
|
|
1069
|
+
/** @returns {TableColumn[]} */
|
|
1070
|
+
_visibleColumns() {
|
|
1071
|
+
return this._columns.filter((column) => !this._hiddenColumns.has(column.id));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/** @returns {HTMLElement} */
|
|
1075
|
+
_createColumnControls() {
|
|
1076
|
+
const list = h('div', { class: 'zx-table__column-list' });
|
|
1077
|
+
this._columnList = list;
|
|
1078
|
+
const details = h('details', { class: 'zx-table__column-controls' },
|
|
1079
|
+
h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }), 'Columns'),
|
|
1080
|
+
list);
|
|
1081
|
+
this._renderColumnControls();
|
|
1082
|
+
return details;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** @param {string|null} [focusColumn=null] Column checkbox that keeps focus. @returns {void} */
|
|
1086
|
+
_renderColumnControls(focusColumn = null) {
|
|
1087
|
+
if (!this._columnList) return;
|
|
1088
|
+
const visible = this._visibleColumns();
|
|
1089
|
+
const current = [...this._columnList.querySelectorAll('.zx-table__column-toggle')];
|
|
1090
|
+
const reusable = current.length === this._columns.length
|
|
1091
|
+
&& current.every((control, index) => /** @type {HTMLInputElement} */ (control).value === this._columns[index].id);
|
|
1092
|
+
if (reusable) {
|
|
1093
|
+
current.forEach((control, index) => {
|
|
1094
|
+
const input = /** @type {HTMLInputElement} */ (control);
|
|
1095
|
+
const checked = !this._hiddenColumns.has(this._columns[index].id);
|
|
1096
|
+
input.checked = checked;
|
|
1097
|
+
input.disabled = checked && visible.length === 1;
|
|
1098
|
+
});
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
this._columnList.replaceChildren(...this._columns.map((column) => {
|
|
1102
|
+
const checked = !this._hiddenColumns.has(column.id);
|
|
1103
|
+
return h('label', { class: 'zx-table__column-option' },
|
|
1104
|
+
h('input', {
|
|
1105
|
+
class: 'zx-table__column-toggle', type: 'checkbox', value: column.id, checked,
|
|
1106
|
+
disabled: checked && visible.length === 1
|
|
1107
|
+
}),
|
|
1108
|
+
h('span', {}, column.label));
|
|
1109
|
+
}));
|
|
1110
|
+
if (focusColumn !== null) {
|
|
1111
|
+
const control = [...this._columnList.querySelectorAll('.zx-table__column-toggle')]
|
|
1112
|
+
.find((candidate) => /** @type {HTMLInputElement} */ (candidate).value === focusColumn);
|
|
1113
|
+
queueMicrotask(() => {
|
|
1114
|
+
if (control?.isConnected) /** @type {HTMLElement} */ (control).focus();
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/** @param {DragEvent} event @returns {void} */
|
|
1120
|
+
_handleRowDragStart(event) {
|
|
1121
|
+
const handle = /** @type {Element|null} */ (event.target)?.closest?.('.zx-table__row-handle');
|
|
1122
|
+
const rowElement = handle?.closest('tr[data-row]');
|
|
1123
|
+
const meta = rowElement ? this._rowMeta.get(rowElement) : null;
|
|
1124
|
+
if (!handle || !meta || this._sort && this.options.sortMode === 'local') {
|
|
1125
|
+
event.preventDefault();
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
this._dragId = meta.id;
|
|
1129
|
+
rowElement.dataset.dragging = 'true';
|
|
1130
|
+
// Native drag needs a payload in some engines, but internal movement already uses `_dragId`.
|
|
1131
|
+
// Keep the transferable marker opaque so record identifiers never leave the table.
|
|
1132
|
+
event.dataTransfer?.setData('text/plain', 'zx-table-row');
|
|
1133
|
+
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** @param {DragEvent} event @returns {void} */
|
|
1137
|
+
_handleRowDragOver(event) {
|
|
1138
|
+
if (this._dragId == null) return;
|
|
1139
|
+
const rowElement = /** @type {Element|null} */ (event.target)?.closest?.('tr[data-row]');
|
|
1140
|
+
const meta = rowElement ? this._rowMeta.get(rowElement) : null;
|
|
1141
|
+
if (!meta || Object.is(meta.id, this._dragId) || !this._canMoveTogether(this._dragId, meta.id)) return;
|
|
1142
|
+
event.preventDefault();
|
|
1143
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
1144
|
+
this._clearDropMarkers();
|
|
1145
|
+
const rectangle = rowElement.getBoundingClientRect();
|
|
1146
|
+
rowElement.dataset.drop = event.clientY >= rectangle.top + rectangle.height / 2 ? 'after' : 'before';
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** @param {DragEvent} event @returns {void} */
|
|
1150
|
+
_handleRowDrop(event) {
|
|
1151
|
+
if (this._dragId == null) return;
|
|
1152
|
+
const rowElement = /** @type {Element|null} */ (event.target)?.closest?.('tr[data-row]');
|
|
1153
|
+
const meta = rowElement ? this._rowMeta.get(rowElement) : null;
|
|
1154
|
+
const position = rowElement?.dataset.drop === 'after' ? 'after' : 'before';
|
|
1155
|
+
if (meta && this._canMoveTogether(this._dragId, meta.id)) {
|
|
1156
|
+
event.preventDefault();
|
|
1157
|
+
this.moveRow(this._dragId, meta.id, position);
|
|
1158
|
+
}
|
|
1159
|
+
this._clearRowDrag();
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
/** @param {KeyboardEvent} event @returns {void} */
|
|
1163
|
+
_handleRowHandleKeydown(event) {
|
|
1164
|
+
const handle = /** @type {Element|null} */ (event.target)?.closest?.('.zx-table__row-handle');
|
|
1165
|
+
const rowElement = handle?.closest('tr[data-row]');
|
|
1166
|
+
const meta = rowElement ? this._rowMeta.get(rowElement) : null;
|
|
1167
|
+
if (!handle || !meta) return;
|
|
1168
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
1169
|
+
event.preventDefault();
|
|
1170
|
+
this._grabbedId = Object.is(this._grabbedId, meta.id) ? null : meta.id;
|
|
1171
|
+
handle.setAttribute('aria-pressed', String(this._grabbedId != null));
|
|
1172
|
+
// Row movement has its own visually hidden live region. Reusing the visible validation
|
|
1173
|
+
// message would make a successful drop look like a red editing error on editable grids.
|
|
1174
|
+
const status = this._rowMoveMessage ?? this._editMessage;
|
|
1175
|
+
if (status) status.textContent = this._grabbedId == null
|
|
1176
|
+
? 'Row dropped' : 'Row grabbed. Use the arrow keys to move it.';
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (event.key === 'Escape' && this._grabbedId != null) {
|
|
1180
|
+
event.preventDefault();
|
|
1181
|
+
this._grabbedId = null;
|
|
1182
|
+
handle.setAttribute('aria-pressed', 'false');
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (!Object.is(this._grabbedId, meta.id) || !['ArrowUp', 'ArrowDown'].includes(event.key)) return;
|
|
1186
|
+
event.preventDefault();
|
|
1187
|
+
const siblings = this._siblingIds(meta.id);
|
|
1188
|
+
const index = siblings.findIndex((id) => Object.is(id, meta.id));
|
|
1189
|
+
const targetId = siblings[index + (event.key === 'ArrowUp' ? -1 : 1)];
|
|
1190
|
+
if (targetId !== undefined) this.moveRow(meta.id, targetId, event.key === 'ArrowUp' ? 'before' : 'after');
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/** @param {unknown} id @returns {unknown[]} */
|
|
1194
|
+
_siblingIds(id) {
|
|
1195
|
+
const row = this.getRow(id);
|
|
1196
|
+
if (!row) return [];
|
|
1197
|
+
const parent = this._hierarchy ? readRowValue(this._hierarchy.parentId, row) : null;
|
|
1198
|
+
return this._data.filter((candidate) => !this._hierarchy
|
|
1199
|
+
|| sameHierarchyParent(readRowValue(this._hierarchy.parentId, candidate), parent))
|
|
1200
|
+
.map((candidate) => this._idFor(candidate));
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** @param {unknown} left @param {unknown} right @returns {boolean} */
|
|
1204
|
+
_canMoveTogether(left, right) {
|
|
1205
|
+
if (!this._hierarchy) return true;
|
|
1206
|
+
const a = this.getRow(left);
|
|
1207
|
+
const b = this.getRow(right);
|
|
1208
|
+
return Boolean(a && b && sameHierarchyParent(
|
|
1209
|
+
readRowValue(this._hierarchy.parentId, a), readRowValue(this._hierarchy.parentId, b)));
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** @param {unknown} id @returns {HTMLElement|null} */
|
|
1213
|
+
_rowHandle(id) {
|
|
1214
|
+
return /** @type {HTMLElement|null} */ (this._rowElements.get(id)?.[0]?.querySelector('.zx-table__row-handle') ?? null);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** @returns {void} */
|
|
1218
|
+
_clearDropMarkers() {
|
|
1219
|
+
for (const row of this._tbody.querySelectorAll('tr[data-drop]')) delete row.dataset.drop;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** @returns {void} */
|
|
1223
|
+
_clearRowDrag() {
|
|
1224
|
+
for (const row of this._tbody.querySelectorAll('tr[data-dragging]')) delete row.dataset.dragging;
|
|
1225
|
+
this._clearDropMarkers();
|
|
1226
|
+
this._dragId = null;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/** @returns {string|null} */
|
|
1230
|
+
_hierarchyColumn() {
|
|
1231
|
+
if (!this._hierarchy) return null;
|
|
1232
|
+
return this._visibleColumns().some((column) => column.id === this._hierarchy.column)
|
|
1233
|
+
? this._hierarchy.column : this._visibleColumns()[0]?.id ?? null;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
954
1236
|
/** @returns {void} */
|
|
955
1237
|
_renderColumns() {
|
|
956
1238
|
const fragment = document.createDocumentFragment();
|
|
1239
|
+
if (this.options.rowReorder) fragment.append(h('col', { class: 'zx-table__reorder-column' }));
|
|
957
1240
|
if (this.options.selectable === 'multi') {
|
|
958
1241
|
fragment.append(h('col', { class: 'zx-table__selection-column' }));
|
|
959
1242
|
}
|
|
960
|
-
const
|
|
1243
|
+
const columns = this._visibleColumns();
|
|
1244
|
+
const fractions = columns.map((column) => parseFraction(column.width));
|
|
961
1245
|
const total = fractions.reduce((sum, value) => sum + value, 0);
|
|
962
1246
|
this._table.dataset.layout = total > 0 ? 'fixed' : 'auto';
|
|
963
|
-
|
|
1247
|
+
columns.forEach((column, index) => {
|
|
964
1248
|
const col = h('col');
|
|
965
1249
|
if (fractions[index] > 0) col.style.width = `${fractions[index] / total * 100}%`;
|
|
966
1250
|
else if (column.width && column.width !== 'auto') col.style.width = String(column.width);
|
|
@@ -973,6 +1257,9 @@ export class Table extends Component {
|
|
|
973
1257
|
_renderHeader() {
|
|
974
1258
|
const row = h('tr', { class: 'zx-table__header-row' });
|
|
975
1259
|
if (this._growStep) row.setAttribute('aria-rowindex', '1');
|
|
1260
|
+
if (this.options.rowReorder) row.append(h('th', {
|
|
1261
|
+
class: 'zx-table__reorder-header', scope: 'col', ariaLabel: 'Row order'
|
|
1262
|
+
}));
|
|
976
1263
|
if (this.options.selectable === 'multi') {
|
|
977
1264
|
this._selectAll = h('input', {
|
|
978
1265
|
class: 'zx-table__checkbox zx-table__select-all',
|
|
@@ -1004,7 +1291,7 @@ export class Table extends Component {
|
|
|
1004
1291
|
}
|
|
1005
1292
|
|
|
1006
1293
|
this._headers = new Map();
|
|
1007
|
-
for (const column of this.
|
|
1294
|
+
for (const column of this._visibleColumns()) {
|
|
1008
1295
|
const th = h('th', { scope: 'col' });
|
|
1009
1296
|
const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
|
|
1010
1297
|
if (align) th.dataset.align = align;
|
|
@@ -1045,7 +1332,8 @@ export class Table extends Component {
|
|
|
1045
1332
|
fragment.append(h('tr', { class: 'zx-table__empty-row' },
|
|
1046
1333
|
h('td', {
|
|
1047
1334
|
class: 'zx-table__empty',
|
|
1048
|
-
colspan: this.
|
|
1335
|
+
colspan: this._visibleColumns().length + (this.options.selectable === 'multi' ? 1 : 0)
|
|
1336
|
+
+ (this.options.rowReorder ? 1 : 0)
|
|
1049
1337
|
}, this._emptyContent())
|
|
1050
1338
|
));
|
|
1051
1339
|
} else {
|
|
@@ -1055,6 +1343,8 @@ export class Table extends Component {
|
|
|
1055
1343
|
});
|
|
1056
1344
|
}
|
|
1057
1345
|
this._tbody.replaceChildren(fragment);
|
|
1346
|
+
this._table.setAttribute('aria-colcount', String(this._visibleColumns().length
|
|
1347
|
+
+ (this.options.selectable === 'multi' ? 1 : 0) + (this.options.rowReorder ? 1 : 0)));
|
|
1058
1348
|
/*
|
|
1059
1349
|
* With `growing` on, the DOM holds a prefix of the data — so without this a screen reader
|
|
1060
1350
|
* announces "row 3 of 10" for a table of ten thousand. The header counts as a row.
|
|
@@ -1103,6 +1393,17 @@ export class Table extends Component {
|
|
|
1103
1393
|
elements.push(tr);
|
|
1104
1394
|
this._rowElements.set(id, elements);
|
|
1105
1395
|
|
|
1396
|
+
if (this.options.rowReorder) {
|
|
1397
|
+
const handle = h('button', {
|
|
1398
|
+
class: 'zx-table__row-handle', type: 'button', draggable: true,
|
|
1399
|
+
dataset: { rowHandle: '' }, ariaLabel: `Move row ${index + 1}`,
|
|
1400
|
+
ariaPressed: String(Object.is(this._grabbedId, id)),
|
|
1401
|
+
disabled: Boolean(this._sort && this.options.sortMode === 'local'),
|
|
1402
|
+
title: this._sort && this.options.sortMode === 'local' ? 'Clear sorting to reorder rows' : null
|
|
1403
|
+
}, icon('drag', { size: 14 }));
|
|
1404
|
+
tr.append(h('td', { class: 'zx-table__reorder-cell' }, handle));
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1106
1407
|
if (this.options.selectable === 'multi') {
|
|
1107
1408
|
const checkbox = h('input', {
|
|
1108
1409
|
class: 'zx-table__checkbox zx-table__row-checkbox',
|
|
@@ -1113,7 +1414,7 @@ export class Table extends Component {
|
|
|
1113
1414
|
tr.append(h('td', { class: 'zx-table__selection-cell' }, checkbox));
|
|
1114
1415
|
}
|
|
1115
1416
|
|
|
1116
|
-
this.
|
|
1417
|
+
this._visibleColumns().forEach((column) => {
|
|
1117
1418
|
const cell = /** @type {HTMLTableCellElement} */ (h('td'));
|
|
1118
1419
|
const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
|
|
1119
1420
|
if (align) cell.dataset.align = align;
|
|
@@ -1150,7 +1451,7 @@ export class Table extends Component {
|
|
|
1150
1451
|
if (value && typeof value === 'object' && typeof value.nodeType === 'number') content.append(value);
|
|
1151
1452
|
else if (value != null) content.append(document.createTextNode(String(value)));
|
|
1152
1453
|
|
|
1153
|
-
if (!this._hierarchy || column.id !== this.
|
|
1454
|
+
if (!this._hierarchy || column.id !== this._hierarchyColumn() || !tree) {
|
|
1154
1455
|
cell.replaceChildren(...content.childNodes);
|
|
1155
1456
|
return;
|
|
1156
1457
|
}
|
|
@@ -1181,6 +1482,7 @@ export class Table extends Component {
|
|
|
1181
1482
|
if (!rowElement || !this._tbody.contains(rowElement)) return;
|
|
1182
1483
|
const meta = this._rowMeta.get(rowElement);
|
|
1183
1484
|
if (!meta) return;
|
|
1485
|
+
if (target.closest('.zx-table__row-handle')) return;
|
|
1184
1486
|
// Read the row metadata first: committing re-renders the body and detaches `rowElement`.
|
|
1185
1487
|
if (this._edit) {
|
|
1186
1488
|
this.commitEdit();
|
|
@@ -1200,6 +1502,16 @@ export class Table extends Component {
|
|
|
1200
1502
|
return;
|
|
1201
1503
|
}
|
|
1202
1504
|
|
|
1505
|
+
if (isInteractiveCellContent(target)) return;
|
|
1506
|
+
|
|
1507
|
+
if (this._editMode !== false && this._editTrigger === 'single') {
|
|
1508
|
+
const cell = target?.closest('td[data-editable="true"]');
|
|
1509
|
+
if (cell && this._tbody.contains(cell)) {
|
|
1510
|
+
this.startEdit(meta.id, String(cell.dataset.column));
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1203
1515
|
this.emit('rowclick', { ...meta, event });
|
|
1204
1516
|
if (this.options.selectable === 'single' && !this._selected.has(meta.id)) {
|
|
1205
1517
|
this._selected = new Set([meta.id]);
|
|
@@ -1218,7 +1530,8 @@ export class Table extends Component {
|
|
|
1218
1530
|
if (!rowElement || !this._tbody.contains(rowElement)) return;
|
|
1219
1531
|
const meta = this._rowMeta.get(rowElement);
|
|
1220
1532
|
if (!meta) return;
|
|
1221
|
-
if (
|
|
1533
|
+
if (isInteractiveCellContent(target)) return;
|
|
1534
|
+
if (this._editMode !== false && this._editTrigger === 'double') {
|
|
1222
1535
|
const cell = target?.closest('td[data-editable="true"]');
|
|
1223
1536
|
if (cell && this._tbody.contains(cell)) {
|
|
1224
1537
|
// A double-click that opens an editor is an edit gesture, not a row activation.
|
|
@@ -1543,13 +1856,14 @@ export class Table extends Component {
|
|
|
1543
1856
|
* @returns {{id: unknown, columnId: string, index: number}|null} Next cell, or null at the end.
|
|
1544
1857
|
*/
|
|
1545
1858
|
_nextEditableCell(rowIndex, columnId, direction) {
|
|
1546
|
-
const
|
|
1859
|
+
const columns = this._visibleColumns();
|
|
1860
|
+
const columnCount = columns.length;
|
|
1547
1861
|
if (rowIndex < 0 || columnCount === 0) return null;
|
|
1548
1862
|
const visible = this._visibleData();
|
|
1549
1863
|
const startingId = this._idFor(this._data[rowIndex]);
|
|
1550
1864
|
let row = visible.findIndex((candidate) => Object.is(this._idFor(candidate), startingId));
|
|
1551
1865
|
if (row < 0) return null;
|
|
1552
|
-
let column =
|
|
1866
|
+
let column = columns.findIndex((candidate) => candidate.id === columnId);
|
|
1553
1867
|
const limit = visible.length * columnCount + columnCount;
|
|
1554
1868
|
for (let step = 0; step < limit; step += 1) {
|
|
1555
1869
|
column += direction;
|
|
@@ -1562,7 +1876,7 @@ export class Table extends Component {
|
|
|
1562
1876
|
}
|
|
1563
1877
|
if (row < 0 || row >= visible.length) return null;
|
|
1564
1878
|
const candidateRow = visible[row];
|
|
1565
|
-
const candidateColumn =
|
|
1879
|
+
const candidateColumn = columns[column];
|
|
1566
1880
|
if (resolveEditable(candidateColumn, candidateRow) !== false) {
|
|
1567
1881
|
return {
|
|
1568
1882
|
id: this._idFor(candidateRow),
|
|
@@ -1896,6 +2210,65 @@ export function sortHierarchyRows(rows, rowId, parentId, getValue, direction) {
|
|
|
1896
2210
|
return result;
|
|
1897
2211
|
}
|
|
1898
2212
|
|
|
2213
|
+
/**
|
|
2214
|
+
* Returns a reordered copy, or null when IDs are missing or hierarchical rows are not siblings.
|
|
2215
|
+
* Moving a parent changes its sibling order; hierarchy projection keeps its descendant subtree
|
|
2216
|
+
* attached as a unit because parent references are never rewritten.
|
|
2217
|
+
* @param {TableRow[]} rows
|
|
2218
|
+
* @param {string|((row:TableRow)=>unknown)} rowId
|
|
2219
|
+
* @param {unknown} id
|
|
2220
|
+
* @param {unknown} targetId
|
|
2221
|
+
* @param {'before'|'after'} [position='before']
|
|
2222
|
+
* @param {string|((row:TableRow)=>unknown)|null} [parentId=null]
|
|
2223
|
+
* @returns {TableRow[]|null}
|
|
2224
|
+
*/
|
|
2225
|
+
export function reorderTableRows(rows, rowId, id, targetId, position = 'before', parentId = null) {
|
|
2226
|
+
if (!Array.isArray(rows) || Object.is(id, targetId)) return null;
|
|
2227
|
+
const from = rows.findIndex((row) => Object.is(readRowValue(rowId, row), id));
|
|
2228
|
+
const target = rows.findIndex((row) => Object.is(readRowValue(rowId, row), targetId));
|
|
2229
|
+
if (from < 0 || target < 0) return null;
|
|
2230
|
+
if (parentId && !sameHierarchyParent(
|
|
2231
|
+
readRowValue(parentId, rows[from]), readRowValue(parentId, rows[target]))) return null;
|
|
2232
|
+
if (parentId) {
|
|
2233
|
+
const forest = hierarchyForest(rows, rowId, parentId);
|
|
2234
|
+
const movingNode = forest.nodes[from];
|
|
2235
|
+
const targetNode = forest.nodes[target];
|
|
2236
|
+
if (!movingNode || !targetNode || movingNode.parent !== targetNode.parent) return null;
|
|
2237
|
+
|
|
2238
|
+
/** @param {TableHierarchyNode} root @returns {TableHierarchyNode[]} */
|
|
2239
|
+
const branchNodes = (root) => {
|
|
2240
|
+
const result = [];
|
|
2241
|
+
const stack = [root];
|
|
2242
|
+
while (stack.length) {
|
|
2243
|
+
const node = /** @type {TableHierarchyNode} */ (stack.pop());
|
|
2244
|
+
result.push(node);
|
|
2245
|
+
for (let index = node.children.length - 1; index >= 0; index -= 1) stack.push(node.children[index]);
|
|
2246
|
+
}
|
|
2247
|
+
return result;
|
|
2248
|
+
};
|
|
2249
|
+
const moving = new Set(branchNodes(movingNode).map((node) => node.index));
|
|
2250
|
+
const targetBranch = new Set(branchNodes(targetNode).map((node) => node.index));
|
|
2251
|
+
const block = rows.filter((_row, index) => moving.has(index));
|
|
2252
|
+
const remaining = rows.filter((_row, index) => !moving.has(index));
|
|
2253
|
+
const remainingSourceIndexes = rows.map((_row, index) => index).filter((index) => !moving.has(index));
|
|
2254
|
+
let insertion = remainingSourceIndexes.findIndex((index) => index === target);
|
|
2255
|
+
if (position === 'after') {
|
|
2256
|
+
const targetPositions = remainingSourceIndexes
|
|
2257
|
+
.map((sourceIndex, index) => targetBranch.has(sourceIndex) ? index : -1)
|
|
2258
|
+
.filter((index) => index >= 0);
|
|
2259
|
+
insertion = Math.max(...targetPositions) + 1;
|
|
2260
|
+
}
|
|
2261
|
+
if (insertion < 0) return null;
|
|
2262
|
+
remaining.splice(insertion, 0, ...block);
|
|
2263
|
+
return remaining;
|
|
2264
|
+
}
|
|
2265
|
+
const next = [...rows];
|
|
2266
|
+
const [moving] = next.splice(from, 1);
|
|
2267
|
+
const adjustedTarget = next.findIndex((row) => Object.is(readRowValue(rowId, row), targetId));
|
|
2268
|
+
next.splice(adjustedTarget + (position === 'after' ? 1 : 0), 0, moving);
|
|
2269
|
+
return next;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
1899
2272
|
/**
|
|
1900
2273
|
* Resolves the editor type of a column for one row.
|
|
1901
2274
|
*
|
|
@@ -2059,7 +2432,8 @@ function hierarchyForest(rows, rowId, parentId) {
|
|
|
2059
2432
|
/** @type {Map<TableHierarchyNode, TableHierarchyNode|null>} */
|
|
2060
2433
|
const parentOf = new Map();
|
|
2061
2434
|
for (const node of nodes) {
|
|
2062
|
-
const
|
|
2435
|
+
const parentValue = readRowValue(parentId, node.row);
|
|
2436
|
+
const parent = parentValue == null ? null : byId.get(parentValue) ?? null;
|
|
2063
2437
|
parentOf.set(node, parent === node ? null : parent);
|
|
2064
2438
|
}
|
|
2065
2439
|
|
|
@@ -2104,6 +2478,16 @@ function readRowValue(accessor, row) {
|
|
|
2104
2478
|
return typeof accessor === 'function' ? accessor(row) : row?.[accessor];
|
|
2105
2479
|
}
|
|
2106
2480
|
|
|
2481
|
+
/**
|
|
2482
|
+
* Null and undefined both identify the hierarchy root; other IDs retain exact identity.
|
|
2483
|
+
* @param {unknown} left
|
|
2484
|
+
* @param {unknown} right
|
|
2485
|
+
* @returns {boolean}
|
|
2486
|
+
*/
|
|
2487
|
+
function sameHierarchyParent(left, right) {
|
|
2488
|
+
return left == null && right == null || Object.is(left, right);
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2107
2491
|
/**
|
|
2108
2492
|
* @param {unknown} option
|
|
2109
2493
|
* @param {TableColumn[]} columns
|
|
@@ -2137,6 +2521,16 @@ function isInsideEditor(target) {
|
|
|
2137
2521
|
return Boolean(target?.closest?.('.zx-table__editor'));
|
|
2138
2522
|
}
|
|
2139
2523
|
|
|
2524
|
+
/** @param {Element|null} target @returns {boolean} */
|
|
2525
|
+
function isInteractiveCellContent(target) {
|
|
2526
|
+
const cell = target?.closest?.('td[data-column]');
|
|
2527
|
+
const interactive = target?.closest?.([
|
|
2528
|
+
'a[href]', 'button', 'input', 'select', 'textarea', 'summary', '[contenteditable="true"]',
|
|
2529
|
+
'[role="button"]', '[role="link"]', '[tabindex]:not([tabindex="-1"])', '[data-zx-table-no-edit]'
|
|
2530
|
+
].join(','));
|
|
2531
|
+
return Boolean(cell && interactive && interactive !== cell && cell.contains(interactive));
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2140
2534
|
/**
|
|
2141
2535
|
* Whether a child component still owns Escape/Enter/Tab because one of its overlays is open.
|
|
2142
2536
|
* @param {Element} target Key event target.
|