@zeyos/zx 3.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 (106) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/DESIGN-SYSTEM.md +87 -71
  3. package/README.md +16 -37
  4. package/dist/types/components/app-icon/app-icon.d.ts +88 -0
  5. package/dist/types/components/app-sidebar/app-sidebar.d.ts +106 -32
  6. package/dist/types/components/card/card.d.ts +128 -0
  7. package/dist/types/components/chart/chart.d.ts +1 -0
  8. package/dist/types/components/filter/filter-model.d.ts +87 -0
  9. package/dist/types/components/filter/filter.d.ts +331 -0
  10. package/dist/types/components/launcher/launcher.d.ts +65 -6
  11. package/dist/types/components/select/select.d.ts +97 -4
  12. package/dist/types/components/table/table.d.ts +107 -3
  13. package/dist/types/components/tag-picker/tag-picker.d.ts +16 -0
  14. package/dist/types/core/dom.d.ts +9 -1
  15. package/dist/types/core/util.d.ts +9 -0
  16. package/dist/types/index.d.ts +6 -2
  17. package/dist/types/{components/app-rail → internal}/app-rail.d.ts +34 -19
  18. package/dist/types/zeyos/icons.d.ts +24 -2
  19. package/dist/types/zeyos/index.d.ts +3 -2
  20. package/dist/types/zeyos/launcher.d.ts +147 -0
  21. package/dist/types/zeyos/select.d.ts +41 -0
  22. package/dist/zx-zeyos.esm.js +2018 -113
  23. package/dist/zx-zeyos.esm.min.js +1 -1
  24. package/dist/zx.css +815 -16
  25. package/dist/zx.esm.js +2661 -338
  26. package/dist/zx.esm.min.js +3 -3
  27. package/dist/zx.global.js +2661 -338
  28. package/dist/zx.global.min.js +3 -3
  29. package/dist/zx.min.css +1 -1
  30. package/docs/api.json +602 -110
  31. package/docs/llms.md +181 -123
  32. package/docs/llms.txt +24 -21
  33. package/package.json +8 -17
  34. package/src/components/app-icon/app-icon.css +105 -0
  35. package/src/components/app-icon/app-icon.js +134 -0
  36. package/src/components/app-sidebar/app-sidebar.css +12 -0
  37. package/src/components/app-sidebar/app-sidebar.js +310 -110
  38. package/src/components/card/card.css +149 -0
  39. package/src/components/card/card.js +285 -0
  40. package/src/components/chart/chart.js +8 -3
  41. package/src/components/filter/filter-model.js +226 -0
  42. package/src/components/filter/filter.css +147 -0
  43. package/src/components/filter/filter.js +554 -0
  44. package/src/components/grid/grid.css +1 -1
  45. package/src/components/grid/grid.js +14 -4
  46. package/src/components/launcher/launcher.css +113 -3
  47. package/src/components/launcher/launcher.js +234 -26
  48. package/src/components/message/message.css +36 -3
  49. package/src/components/select/field-adapter.js +9 -4
  50. package/src/components/select/select.css +161 -10
  51. package/src/components/select/select.js +361 -48
  52. package/src/components/table/table.css +85 -0
  53. package/src/components/table/table.js +426 -31
  54. package/src/components/tag-picker/tag-picker.css +19 -0
  55. package/src/components/tag-picker/tag-picker.js +42 -5
  56. package/src/core/dom.js +21 -1
  57. package/src/core/util.js +20 -0
  58. package/src/index.js +9 -2
  59. package/src/{components/app-rail → internal}/app-rail.js +80 -30
  60. package/src/zeyos/icons.js +38 -20
  61. package/src/zeyos/index.js +8 -2
  62. package/src/zeyos/launcher.js +338 -0
  63. package/src/zeyos/modules.js +1 -0
  64. package/src/zeyos/select.js +116 -1
  65. package/styles/tokens/global.css +1 -1
  66. package/styles/tokens/modules.css +1 -0
  67. package/styles/tokens/semantic.css +9 -0
  68. package/styles/tokens/themes.css +2 -2
  69. package/styles/zx.css +3 -0
  70. package/MIGRATION.md +0 -749
  71. package/dist/types/compat/base.d.ts +0 -156
  72. package/dist/types/compat/globals.d.ts +0 -20
  73. package/dist/types/compat/index.d.ts +0 -128
  74. package/dist/types/compat/map/basic.d.ts +0 -175
  75. package/dist/types/compat/map/data.d.ts +0 -103
  76. package/dist/types/compat/map/date-time.d.ts +0 -80
  77. package/dist/types/compat/map/factory.d.ts +0 -9
  78. package/dist/types/compat/map/form.d.ts +0 -98
  79. package/dist/types/compat/map/helpers.d.ts +0 -12
  80. package/dist/types/compat/map/http.d.ts +0 -36
  81. package/dist/types/compat/map/layout.d.ts +0 -106
  82. package/dist/types/compat/map/options.d.ts +0 -80
  83. package/dist/types/compat/map/select.d.ts +0 -95
  84. package/dist/types/compat/map/stubs.d.ts +0 -32
  85. package/dist/types/compat/map/utilities.d.ts +0 -37
  86. package/dist/types/compat-entry.d.ts +0 -5
  87. package/dist/zx-compat.esm.js +0 -17848
  88. package/dist/zx-compat.esm.min.js +0 -1
  89. package/dist/zx-compat.global.js +0 -29008
  90. package/dist/zx-compat.global.min.js +0 -3
  91. package/src/compat/base.js +0 -291
  92. package/src/compat/globals.js +0 -167
  93. package/src/compat/index.js +0 -84
  94. package/src/compat/map/basic.js +0 -464
  95. package/src/compat/map/data.js +0 -254
  96. package/src/compat/map/date-time.js +0 -177
  97. package/src/compat/map/factory.js +0 -45
  98. package/src/compat/map/form.js +0 -330
  99. package/src/compat/map/helpers.js +0 -46
  100. package/src/compat/map/http.js +0 -117
  101. package/src/compat/map/layout.js +0 -256
  102. package/src/compat/map/options.js +0 -267
  103. package/src/compat/map/select.js +0 -198
  104. package/src/compat/map/stubs.js +0 -26
  105. package/src/compat/map/utilities.js +0 -113
  106. package/src/compat-entry.js +0 -11
@@ -1,6 +1,6 @@
1
1
  import { Component } from '../../core/component.js';
2
2
  import { breakpoints, onBreakpoint } from '../../core/breakpoint.js';
3
- import { h } from '../../core/dom.js';
3
+ import { h, restoreTarget, snapshotTarget } from '../../core/dom.js';
4
4
  import { formatCurrency, formatNumber, formatPercent } from '../../core/format.js';
5
5
  import { icon } from '../../core/icons.js';
6
6
  import { printf } from '../../core/i18n.js';
@@ -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
  /**
@@ -261,11 +274,13 @@ export class Table extends Component {
261
274
 
262
275
  /** @returns {HTMLElement} Table root. */
263
276
  render() {
277
+ const created = this.el === null;
264
278
  const root = /** @type {HTMLElement} */ (this.el ?? h('div'));
265
279
  // Claimed here, not left to the base constructor: `_watchWidth()` below observes `this.el`,
266
280
  // and until this assignment it is null for a component creating its own root.
267
281
  this.el = root;
268
- this._originalChildren = this.el ? Array.from(this.el.childNodes) : null;
282
+ this._createdRoot = created;
283
+ this._original = created ? null : snapshotTarget(root);
269
284
  this._columns = Array.isArray(this.options.columns) ? this.options.columns.map((column) => ({ ...column })) : [];
270
285
  this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
271
286
  this._selected = new Set();
@@ -277,6 +292,7 @@ export class Table extends Component {
277
292
  // Editing state. `_edit` is null unless a cell or row is currently open for editing; every
278
293
  // other member below is only ever touched while `_editMode !== false`.
279
294
  this._editMode = normalizeEditMode(this.options.editMode);
295
+ this._editTrigger = this.options.editTrigger === 'single' ? 'single' : 'double';
280
296
  this._edit = null;
281
297
  this._editMessage = null;
282
298
  this._stacked = false;
@@ -288,6 +304,11 @@ export class Table extends Component {
288
304
  this._rovingCell = null;
289
305
  this._rovingMatched = false;
290
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;
291
312
  this._expanded = initialExpanded(this._hierarchy?.expanded);
292
313
 
293
314
  if (this._sort && this.options.sortMode === 'local') this._sortData();
@@ -312,12 +333,16 @@ export class Table extends Component {
312
333
  zebra: String(Boolean(this.options.zebra))
313
334
  }
314
335
  }, this._table);
336
+ this._rowMoveMessage = this.options.rowReorder ? h('div', {
337
+ class: 'zx-table__reorder-status', role: 'status', ariaLive: 'polite'
338
+ }) : null;
315
339
  if (this.options.height != null) {
316
340
  this._scroll.style.blockSize = typeof this.options.height === 'number' ?
317
341
  `${this.options.height}px` : String(this.options.height);
318
342
  }
343
+ this._columnControls = this.options.columnVisibility ? this._createColumnControls() : null;
319
344
  if (this._editMode === false) {
320
- root.replaceChildren(this._scroll);
345
+ root.replaceChildren(...[this._columnControls, this._scroll, this._rowMoveMessage].filter(Boolean));
321
346
  } else {
322
347
  root.dataset.editMode = this._editMode;
323
348
  // Always present and empty rather than hidden: a live region that is revealed together with
@@ -328,7 +353,7 @@ export class Table extends Component {
328
353
  role: 'status',
329
354
  ariaLive: 'polite'
330
355
  });
331
- root.replaceChildren(this._scroll, this._editMessage);
356
+ root.replaceChildren(...[this._columnControls, this._scroll, this._editMessage, this._rowMoveMessage].filter(Boolean));
332
357
  }
333
358
 
334
359
  this._renderColumns();
@@ -337,6 +362,19 @@ export class Table extends Component {
337
362
  this._watchWidth();
338
363
  this.listen(this._tbody, 'click', (event) => this._handleBodyClick(event));
339
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
+ }
340
378
  if (this._editMode !== false) {
341
379
  this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
342
380
  this.listen(this._tbody, 'focusin', (event) => this._handleEditFocusIn(event));
@@ -347,8 +385,8 @@ export class Table extends Component {
347
385
 
348
386
  /**
349
387
  * Toggles a loading state. While loading, the body is dimmed, an indeterminate progress bar is
350
- * shown, and `aria-busy` is set — a lightweight take on the busy/skeleton pattern used by
351
- * enterprise tables (SAP UI5, Salesforce SLDS). Call `setLoading(false)` (or `setData`) when done.
388
+ * shown, and `aria-busy` is set — a lightweight enterprise-table busy and skeleton pattern.
389
+ * Call `setLoading(false)` (or `setData`) when done.
352
390
  * @param {boolean} [loading=true] Whether the table is loading.
353
391
  * @returns {this}
354
392
  */
@@ -458,6 +496,80 @@ export class Table extends Component {
458
496
  return [...this._data];
459
497
  }
460
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
+
461
573
  /**
462
574
  * Removes all rows.
463
575
  * @returns {this}
@@ -639,15 +751,20 @@ export class Table extends Component {
639
751
  */
640
752
  startEdit(rowId, columnId) {
641
753
  if (this._editMode === false) return this;
642
- const column = this._columns.find((candidate) => candidate.id === columnId);
643
- if (!column) return this;
754
+ const requested = this._columns.find((candidate) => candidate.id === columnId);
755
+ if (!requested) return this;
644
756
  const current = this._data.find((candidate) => Object.is(this._idFor(candidate), rowId));
645
- 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;
646
763
 
647
764
  if (this._edit) {
648
- if (Object.is(this._edit.id, rowId) && this._edit.cells.has(columnId)) {
649
- this._edit.columnId = columnId;
650
- 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);
651
768
  return this;
652
769
  }
653
770
  this.commitEdit();
@@ -662,7 +779,7 @@ export class Table extends Component {
662
779
  const tr = this._rowElements.get(rowId)?.[0];
663
780
  if (!tr) return this;
664
781
  const targets = this._editMode === 'row'
665
- ? this._columns.filter((candidate) => resolveEditable(candidate, row) !== false)
782
+ ? this._visibleColumns().filter((candidate) => resolveEditable(candidate, row) !== false)
666
783
  : [column];
667
784
  /** @type {Map<string, TableEditorEntry>} */
668
785
  const cells = new Map();
@@ -674,10 +791,10 @@ export class Table extends Component {
674
791
  }
675
792
  if (cells.size === 0) return this;
676
793
 
677
- this._edit = { id: rowId, columnId, row, cells, tr };
794
+ this._edit = { id: rowId, columnId: activeColumnId, row, cells, tr };
678
795
  tr.dataset.editing = 'true';
679
- this.emit('editstart', { row, id: rowId, column, columnId, value: row?.[columnId] });
680
- this._focusEditor(columnId);
796
+ this.emit('editstart', { row, id: rowId, column, columnId: activeColumnId, value: row?.[activeColumnId] });
797
+ this._focusEditor(activeColumnId);
681
798
  return this;
682
799
  }
683
800
 
@@ -942,24 +1059,192 @@ export class Table extends Component {
942
1059
  this._teardownEdit();
943
1060
  this._width?.destroy();
944
1061
  this._width = null;
945
- if (this.el) delete this.el.dataset.editMode;
946
- if (!this._restored && this._originalChildren) {
947
- this.el.replaceChildren(...this._originalChildren);
1062
+ if (!this._restored && !this._createdRoot) {
1063
+ restoreTarget(this.el, this._original);
948
1064
  this._restored = true;
949
1065
  }
950
1066
  super.destroy();
951
1067
  }
952
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
+
953
1236
  /** @returns {void} */
954
1237
  _renderColumns() {
955
1238
  const fragment = document.createDocumentFragment();
1239
+ if (this.options.rowReorder) fragment.append(h('col', { class: 'zx-table__reorder-column' }));
956
1240
  if (this.options.selectable === 'multi') {
957
1241
  fragment.append(h('col', { class: 'zx-table__selection-column' }));
958
1242
  }
959
- const fractions = this._columns.map((column) => parseFraction(column.width));
1243
+ const columns = this._visibleColumns();
1244
+ const fractions = columns.map((column) => parseFraction(column.width));
960
1245
  const total = fractions.reduce((sum, value) => sum + value, 0);
961
1246
  this._table.dataset.layout = total > 0 ? 'fixed' : 'auto';
962
- this._columns.forEach((column, index) => {
1247
+ columns.forEach((column, index) => {
963
1248
  const col = h('col');
964
1249
  if (fractions[index] > 0) col.style.width = `${fractions[index] / total * 100}%`;
965
1250
  else if (column.width && column.width !== 'auto') col.style.width = String(column.width);
@@ -972,6 +1257,9 @@ export class Table extends Component {
972
1257
  _renderHeader() {
973
1258
  const row = h('tr', { class: 'zx-table__header-row' });
974
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
+ }));
975
1263
  if (this.options.selectable === 'multi') {
976
1264
  this._selectAll = h('input', {
977
1265
  class: 'zx-table__checkbox zx-table__select-all',
@@ -1003,7 +1291,7 @@ export class Table extends Component {
1003
1291
  }
1004
1292
 
1005
1293
  this._headers = new Map();
1006
- for (const column of this._columns) {
1294
+ for (const column of this._visibleColumns()) {
1007
1295
  const th = h('th', { scope: 'col' });
1008
1296
  const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1009
1297
  if (align) th.dataset.align = align;
@@ -1044,7 +1332,8 @@ export class Table extends Component {
1044
1332
  fragment.append(h('tr', { class: 'zx-table__empty-row' },
1045
1333
  h('td', {
1046
1334
  class: 'zx-table__empty',
1047
- 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)
1048
1337
  }, this._emptyContent())
1049
1338
  ));
1050
1339
  } else {
@@ -1054,6 +1343,8 @@ export class Table extends Component {
1054
1343
  });
1055
1344
  }
1056
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)));
1057
1348
  /*
1058
1349
  * With `growing` on, the DOM holds a prefix of the data — so without this a screen reader
1059
1350
  * announces "row 3 of 10" for a table of ten thousand. The header counts as a row.
@@ -1102,6 +1393,17 @@ export class Table extends Component {
1102
1393
  elements.push(tr);
1103
1394
  this._rowElements.set(id, elements);
1104
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
+
1105
1407
  if (this.options.selectable === 'multi') {
1106
1408
  const checkbox = h('input', {
1107
1409
  class: 'zx-table__checkbox zx-table__row-checkbox',
@@ -1112,7 +1414,7 @@ export class Table extends Component {
1112
1414
  tr.append(h('td', { class: 'zx-table__selection-cell' }, checkbox));
1113
1415
  }
1114
1416
 
1115
- this._columns.forEach((column) => {
1417
+ this._visibleColumns().forEach((column) => {
1116
1418
  const cell = /** @type {HTMLTableCellElement} */ (h('td'));
1117
1419
  const align = column.align ?? (isNumericColumn(column) ? 'end' : null);
1118
1420
  if (align) cell.dataset.align = align;
@@ -1149,7 +1451,7 @@ export class Table extends Component {
1149
1451
  if (value && typeof value === 'object' && typeof value.nodeType === 'number') content.append(value);
1150
1452
  else if (value != null) content.append(document.createTextNode(String(value)));
1151
1453
 
1152
- if (!this._hierarchy || column.id !== this._hierarchy.column || !tree) {
1454
+ if (!this._hierarchy || column.id !== this._hierarchyColumn() || !tree) {
1153
1455
  cell.replaceChildren(...content.childNodes);
1154
1456
  return;
1155
1457
  }
@@ -1180,6 +1482,7 @@ export class Table extends Component {
1180
1482
  if (!rowElement || !this._tbody.contains(rowElement)) return;
1181
1483
  const meta = this._rowMeta.get(rowElement);
1182
1484
  if (!meta) return;
1485
+ if (target.closest('.zx-table__row-handle')) return;
1183
1486
  // Read the row metadata first: committing re-renders the body and detaches `rowElement`.
1184
1487
  if (this._edit) {
1185
1488
  this.commitEdit();
@@ -1199,6 +1502,16 @@ export class Table extends Component {
1199
1502
  return;
1200
1503
  }
1201
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
+
1202
1515
  this.emit('rowclick', { ...meta, event });
1203
1516
  if (this.options.selectable === 'single' && !this._selected.has(meta.id)) {
1204
1517
  this._selected = new Set([meta.id]);
@@ -1217,7 +1530,8 @@ export class Table extends Component {
1217
1530
  if (!rowElement || !this._tbody.contains(rowElement)) return;
1218
1531
  const meta = this._rowMeta.get(rowElement);
1219
1532
  if (!meta) return;
1220
- if (this._editMode !== false) {
1533
+ if (isInteractiveCellContent(target)) return;
1534
+ if (this._editMode !== false && this._editTrigger === 'double') {
1221
1535
  const cell = target?.closest('td[data-editable="true"]');
1222
1536
  if (cell && this._tbody.contains(cell)) {
1223
1537
  // A double-click that opens an editor is an edit gesture, not a row activation.
@@ -1542,13 +1856,14 @@ export class Table extends Component {
1542
1856
  * @returns {{id: unknown, columnId: string, index: number}|null} Next cell, or null at the end.
1543
1857
  */
1544
1858
  _nextEditableCell(rowIndex, columnId, direction) {
1545
- const columnCount = this._columns.length;
1859
+ const columns = this._visibleColumns();
1860
+ const columnCount = columns.length;
1546
1861
  if (rowIndex < 0 || columnCount === 0) return null;
1547
1862
  const visible = this._visibleData();
1548
1863
  const startingId = this._idFor(this._data[rowIndex]);
1549
1864
  let row = visible.findIndex((candidate) => Object.is(this._idFor(candidate), startingId));
1550
1865
  if (row < 0) return null;
1551
- let column = this._columns.findIndex((candidate) => candidate.id === columnId);
1866
+ let column = columns.findIndex((candidate) => candidate.id === columnId);
1552
1867
  const limit = visible.length * columnCount + columnCount;
1553
1868
  for (let step = 0; step < limit; step += 1) {
1554
1869
  column += direction;
@@ -1561,7 +1876,7 @@ export class Table extends Component {
1561
1876
  }
1562
1877
  if (row < 0 || row >= visible.length) return null;
1563
1878
  const candidateRow = visible[row];
1564
- const candidateColumn = this._columns[column];
1879
+ const candidateColumn = columns[column];
1565
1880
  if (resolveEditable(candidateColumn, candidateRow) !== false) {
1566
1881
  return {
1567
1882
  id: this._idFor(candidateRow),
@@ -1895,6 +2210,65 @@ export function sortHierarchyRows(rows, rowId, parentId, getValue, direction) {
1895
2210
  return result;
1896
2211
  }
1897
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
+
1898
2272
  /**
1899
2273
  * Resolves the editor type of a column for one row.
1900
2274
  *
@@ -2058,7 +2432,8 @@ function hierarchyForest(rows, rowId, parentId) {
2058
2432
  /** @type {Map<TableHierarchyNode, TableHierarchyNode|null>} */
2059
2433
  const parentOf = new Map();
2060
2434
  for (const node of nodes) {
2061
- 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;
2062
2437
  parentOf.set(node, parent === node ? null : parent);
2063
2438
  }
2064
2439
 
@@ -2103,6 +2478,16 @@ function readRowValue(accessor, row) {
2103
2478
  return typeof accessor === 'function' ? accessor(row) : row?.[accessor];
2104
2479
  }
2105
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
+
2106
2491
  /**
2107
2492
  * @param {unknown} option
2108
2493
  * @param {TableColumn[]} columns
@@ -2136,6 +2521,16 @@ function isInsideEditor(target) {
2136
2521
  return Boolean(target?.closest?.('.zx-table__editor'));
2137
2522
  }
2138
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
+
2139
2534
  /**
2140
2535
  * Whether a child component still owns Escape/Enter/Tab because one of its overlays is open.
2141
2536
  * @param {Element} target Key event target.