@zeyos/zx 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/DESIGN-SYSTEM.md +59 -3
  3. package/dist/types/components/app-icon/app-icon.d.ts +88 -0
  4. package/dist/types/components/filter/filter-model.d.ts +87 -0
  5. package/dist/types/components/filter/filter.d.ts +331 -0
  6. package/dist/types/components/select/select.d.ts +46 -4
  7. package/dist/types/components/table/table.d.ts +103 -0
  8. package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
  9. package/dist/types/core/dom.d.ts +1 -1
  10. package/dist/types/core/util.d.ts +9 -0
  11. package/dist/types/index.d.ts +4 -1
  12. package/dist/types/zeyos/icons.d.ts +19 -2
  13. package/dist/types/zeyos/index.d.ts +1 -1
  14. package/dist/types/zeyos/select.d.ts +2 -0
  15. package/dist/zx-zeyos.esm.js +702 -75
  16. package/dist/zx-zeyos.esm.min.js +1 -1
  17. package/dist/zx.css +415 -4
  18. package/dist/zx.esm.js +1568 -115
  19. package/dist/zx.esm.min.js +3 -3
  20. package/dist/zx.global.js +1568 -115
  21. package/dist/zx.global.min.js +3 -3
  22. package/dist/zx.min.css +1 -1
  23. package/docs/api.json +379 -0
  24. package/docs/llms.md +93 -16
  25. package/docs/llms.txt +11 -9
  26. package/package.json +1 -1
  27. package/src/components/app-icon/app-icon.css +105 -0
  28. package/src/components/app-icon/app-icon.js +134 -0
  29. package/src/components/filter/filter-model.js +226 -0
  30. package/src/components/filter/filter.css +147 -0
  31. package/src/components/filter/filter.js +554 -0
  32. package/src/components/grid/grid.css +1 -1
  33. package/src/components/grid/grid.js +4 -1
  34. package/src/components/message/message.css +36 -3
  35. package/src/components/select/select.css +17 -0
  36. package/src/components/select/select.js +105 -24
  37. package/src/components/table/table.css +85 -0
  38. package/src/components/table/table.js +418 -24
  39. package/src/components/tag-picker/tag-picker.css +19 -0
  40. package/src/components/tag-picker/tag-picker.js +42 -5
  41. package/src/core/dom.js +1 -1
  42. package/src/core/util.js +20 -0
  43. package/src/index.js +7 -1
  44. package/src/zeyos/icons.js +34 -21
  45. package/src/zeyos/index.js +1 -1
  46. package/src/zeyos/select.js +6 -5
  47. package/styles/tokens/semantic.css +9 -0
  48. package/styles/tokens/themes.css +2 -2
  49. 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
- hierarchy: false
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 column = this._columns.find((candidate) => candidate.id === columnId);
645
- if (!column) return this;
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 || resolveEditable(column, current) === false) return this;
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(columnId)) {
651
- this._edit.columnId = columnId;
652
- this._focusEditor(columnId);
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._columns.filter((candidate) => resolveEditable(candidate, row) !== false)
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?.[columnId] });
682
- this._focusEditor(columnId);
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 fractions = this._columns.map((column) => parseFraction(column.width));
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
- this._columns.forEach((column, index) => {
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._columns) {
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._columns.length + (this.options.selectable === 'multi' ? 1 : 0)
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._columns.forEach((column) => {
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._hierarchy.column || !tree) {
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 (this._editMode !== false) {
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 columnCount = this._columns.length;
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 = this._columns.findIndex((candidate) => candidate.id === columnId);
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 = this._columns[column];
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 parent = byId.get(readRowValue(parentId, node.row)) ?? null;
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.