@zeyos/zx 4.1.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/DESIGN-SYSTEM.md +33 -3
  3. package/README.md +17 -5
  4. package/dist/types/components/app-icon/app-icon.d.ts +8 -1
  5. package/dist/types/components/aurora/aurora.d.ts +72 -0
  6. package/dist/types/components/calendar/calendar-model.d.ts +202 -0
  7. package/dist/types/components/calendar/calendar.d.ts +500 -0
  8. package/dist/types/components/card-view/card-view.d.ts +303 -0
  9. package/dist/types/components/card-view/record-card.d.ts +242 -0
  10. package/dist/types/components/kanban-view/kanban-view.d.ts +572 -0
  11. package/dist/types/components/table/table.d.ts +120 -31
  12. package/dist/types/components/table-view/table-view.d.ts +192 -0
  13. package/dist/types/components/view/record-view.d.ts +464 -0
  14. package/dist/types/index.d.ts +7 -0
  15. package/dist/types/zeyos/calendar.d.ts +91 -0
  16. package/dist/types/zeyos/icons.d.ts +19 -3
  17. package/dist/types/zeyos/index.d.ts +4 -0
  18. package/dist/types/zeyos/legacy-saved-views.d.ts +66 -0
  19. package/dist/types/zeyos/saved-views.d.ts +331 -0
  20. package/dist/types/zeyos/view.d.ts +305 -0
  21. package/dist/zx-zeyos.esm.js +4627 -1460
  22. package/dist/zx-zeyos.esm.min.js +1 -1
  23. package/dist/zx.css +2492 -410
  24. package/dist/zx.esm.js +4891 -115
  25. package/dist/zx.esm.min.js +3 -3
  26. package/dist/zx.global.js +4891 -115
  27. package/dist/zx.global.min.js +3 -3
  28. package/dist/zx.min.css +1 -1
  29. package/docs/api.json +1702 -191
  30. package/docs/llms.md +207 -20
  31. package/docs/llms.txt +16 -6
  32. package/package.json +1 -1
  33. package/src/components/app-icon/app-icon.css +140 -30
  34. package/src/components/app-icon/app-icon.js +32 -5
  35. package/src/components/app-rail/app-rail.css +11 -2
  36. package/src/components/app-sidebar/app-sidebar.css +11 -2
  37. package/src/components/aurora/aurora.css +143 -0
  38. package/src/components/aurora/aurora.js +146 -0
  39. package/src/components/avatar/avatar.css +1 -1
  40. package/src/components/button/button.css +34 -2
  41. package/src/components/calendar/calendar-model.js +402 -0
  42. package/src/components/calendar/calendar.css +567 -0
  43. package/src/components/calendar/calendar.js +1524 -0
  44. package/src/components/card/card.css +25 -3
  45. package/src/components/card-view/card-view.css +389 -0
  46. package/src/components/card-view/card-view.js +527 -0
  47. package/src/components/card-view/record-card.js +392 -0
  48. package/src/components/context-menu/context-menu.css +2 -2
  49. package/src/components/date-picker/date-picker.css +15 -3
  50. package/src/components/datebox/date-range-box.css +15 -2
  51. package/src/components/datebox/datebox.css +16 -3
  52. package/src/components/dropdown/dropdown.css +15 -3
  53. package/src/components/kanban-view/kanban-view.css +216 -0
  54. package/src/components/kanban-view/kanban-view.js +1228 -0
  55. package/src/components/launcher/launcher.css +18 -5
  56. package/src/components/master-panel/master-panel.css +14 -3
  57. package/src/components/menu-button/menu-button.css +2 -2
  58. package/src/components/message/message.css +15 -10
  59. package/src/components/modal/modal.css +15 -3
  60. package/src/components/navigation-bar/navigation-bar.css +17 -5
  61. package/src/components/panel/panel.css +10 -2
  62. package/src/components/select/select.css +20 -4
  63. package/src/components/sheet/sheet.css +4 -1
  64. package/src/components/table/table.css +80 -4
  65. package/src/components/table/table.js +322 -37
  66. package/src/components/table-view/table-view.css +18 -0
  67. package/src/components/table-view/table-view.js +347 -0
  68. package/src/components/tag-picker/tag-picker.css +15 -3
  69. package/src/components/tooltip/tooltip.css +15 -3
  70. package/src/components/view/record-view.css +114 -0
  71. package/src/components/view/record-view.js +770 -0
  72. package/src/index.js +15 -0
  73. package/src/zeyos/calendar.js +115 -0
  74. package/src/zeyos/icons.js +15 -8
  75. package/src/zeyos/index.js +13 -0
  76. package/src/zeyos/launcher.js +2 -2
  77. package/src/zeyos/legacy-saved-views.js +155 -0
  78. package/src/zeyos/saved-views.js +815 -0
  79. package/src/zeyos/table.js +3 -1
  80. package/src/zeyos/view.js +543 -0
  81. package/styles/icons.css +3 -10
  82. package/styles/tokens/semantic.css +74 -2
  83. package/styles/zx.css +6 -0
@@ -107,6 +107,8 @@ import { sortRows } from './sort.js';
107
107
  * @property {'single'|'double'} [editTrigger='double'] Gesture that starts an editable cell.
108
108
  * @property {boolean} [rowReorder=false] Whether rows expose pointer/keyboard reorder handles.
109
109
  * @property {boolean} [columnVisibility=false] Whether a column visibility chooser is rendered.
110
+ * @property {boolean} [columnReorder=false] Whether the column chooser also exposes accessible
111
+ * earlier/later controls. Column headers never become draggable.
110
112
  * @property {string[]} [hiddenColumns=[]] Initially hidden column IDs; at least one stays visible.
111
113
  * @property {false|TableHierarchyOptions} [hierarchy=false] Flat parent references projected as an
112
114
  * expandable treegrid. Row data and mutation APIs remain flat.
@@ -126,6 +128,7 @@ import { sortRows } from './sort.js';
126
128
  * @property {(event: CustomEvent<TableRowToggleDetail>) => void} [onrowtoggle]
127
129
  * @property {(event: CustomEvent<TableRowMoveDetail>) => void} [onrowmove]
128
130
  * @property {(event: CustomEvent<{visible:string[],hidden:string[]}>) => void} [oncolumnvisibilitychange]
131
+ * @property {(event: CustomEvent<{order:string[]}>) => void} [oncolumnorderchange]
129
132
  */
130
133
 
131
134
  /**
@@ -136,7 +139,7 @@ import { sortRows } from './sort.js';
136
139
  * @property {Event} event
137
140
  */
138
141
 
139
- /** @typedef {{id: string, dir: TableSortDirection}} TableSortDetail */
142
+ /** @typedef {{id: string|null, dir: TableSortDirection|null}} TableSortDetail */
140
143
  /** @typedef {{rows: TableRow[], ids: unknown[]}} TableSelectionDetail */
141
144
  /** @typedef {{rows: TableRow[]}} TableDataDetail */
142
145
  /** @typedef {{row: TableRow, id: unknown, expanded: boolean}} TableRowToggleDetail */
@@ -235,6 +238,7 @@ const FOCUSABLE = 'input, textarea, select, button, [tabindex]:not([tabindex="-1
235
238
  * @fires Table#grow
236
239
  * @fires Table#rowmove
237
240
  * @fires Table#columnvisibilitychange
241
+ * @fires Table#columnorderchange
238
242
  * @extends {Component<TableOptions>}
239
243
  */
240
244
  export class Table extends Component {
@@ -260,6 +264,7 @@ export class Table extends Component {
260
264
  hierarchy: false,
261
265
  rowReorder: false,
262
266
  columnVisibility: false,
267
+ columnReorder: false,
263
268
  hiddenColumns: []
264
269
  };
265
270
 
@@ -281,7 +286,7 @@ export class Table extends Component {
281
286
  this.el = root;
282
287
  this._createdRoot = created;
283
288
  this._original = created ? null : snapshotTarget(root);
284
- this._columns = Array.isArray(this.options.columns) ? this.options.columns.map((column) => ({ ...column })) : [];
289
+ this._columns = normalizeTableColumns(this.options.columns);
285
290
  this._data = Array.isArray(this.options.data) ? [...this.options.data] : [];
286
291
  this._selected = new Set();
287
292
  this._selectionAnchorId = null;
@@ -309,6 +314,8 @@ export class Table extends Component {
309
314
  if (this._hiddenColumns.size >= this._columns.length && this._columns[0]) this._hiddenColumns.delete(this._columns[0].id);
310
315
  this._dragId = null;
311
316
  this._grabbedId = null;
317
+ this._columnControlIds = new Map();
318
+ this._columnStatus = null;
312
319
  this._expanded = initialExpanded(this._hierarchy?.expanded);
313
320
 
314
321
  if (this._sort && this.options.sortMode === 'local') this._sortData();
@@ -340,7 +347,8 @@ export class Table extends Component {
340
347
  this._scroll.style.blockSize = typeof this.options.height === 'number' ?
341
348
  `${this.options.height}px` : String(this.options.height);
342
349
  }
343
- this._columnControls = this.options.columnVisibility ? this._createColumnControls() : null;
350
+ this._columnControls = this.options.columnVisibility || this.options.columnReorder
351
+ ? this._createColumnControls() : null;
344
352
  if (this._editMode === false) {
345
353
  root.replaceChildren(...[this._columnControls, this._scroll, this._rowMoveMessage].filter(Boolean));
346
354
  } else {
@@ -374,6 +382,9 @@ export class Table extends Component {
374
382
  const input = /** @type {HTMLInputElement|null} */ (event.target);
375
383
  if (input?.classList.contains('zx-table__column-toggle')) this.setColumnVisible(input.value, input.checked);
376
384
  });
385
+ this.listen(this._columnControls, 'click', (event) => this._handleColumnControlClick(event));
386
+ this.listen(this._columnControls, 'keydown', (event) =>
387
+ this._handleColumnControlKeydown(/** @type {KeyboardEvent} */ (event)));
377
388
  }
378
389
  if (this._editMode !== false) {
379
390
  this.listen(this._tbody, 'keydown', (event) => this._handleEditKeydown(/** @type {KeyboardEvent} */ (event)));
@@ -496,6 +507,108 @@ export class Table extends Component {
496
507
  return [...this._data];
497
508
  }
498
509
 
510
+ /**
511
+ * Returns shallow-cloned column descriptors in their current display order.
512
+ * @returns {TableColumn[]} Current columns.
513
+ */
514
+ getColumns() {
515
+ return this._columns.map((column) => ({ ...column }));
516
+ }
517
+
518
+ /**
519
+ * Replaces all column descriptors. Hidden state and the active sort follow retained stable IDs;
520
+ * an edit is canceled before cells are rebuilt.
521
+ * @param {TableColumn[]} columns New column descriptors.
522
+ * @param {{silent?:boolean}} [options={}] Event behavior.
523
+ * @returns {this}
524
+ * @fires Table#columnvisibilitychange
525
+ * @fires Table#columnorderchange
526
+ * @fires Table#editcancel
527
+ */
528
+ setColumns(columns, options = {}) {
529
+ const next = normalizeTableColumns(columns);
530
+ const previousOrder = this.getColumnOrder();
531
+ const previousHidden = this.getHiddenColumns();
532
+ const previousSort = this.getSort();
533
+ this._abortEdit({ focus: false });
534
+ this._columns = next;
535
+ this._hiddenColumns = new Set(previousHidden.filter((id) =>
536
+ next.some((column) => column.id === id)));
537
+ if (this._hiddenColumns.size >= next.length && next[0]) this._hiddenColumns.delete(next[0].id);
538
+ this._sort = normalizeSort(previousSort, next);
539
+ this._hierarchy = normalizeHierarchy(this.options.hierarchy, next);
540
+ this._reconcileExpanded();
541
+ this._renderColumns();
542
+ this._renderHeader();
543
+ this._renderBody();
544
+ this._renderColumnControls();
545
+ if (!options.silent) {
546
+ if (!sameArray(previousOrder, this.getColumnOrder())) {
547
+ this.emit('columnorderchange', { order: this.getColumnOrder() });
548
+ }
549
+ if (!sameArray(previousHidden, this.getHiddenColumns())) {
550
+ this.emit('columnvisibilitychange', {
551
+ visible: this.getVisibleColumns(), hidden: this.getHiddenColumns()
552
+ });
553
+ }
554
+ }
555
+ return this;
556
+ }
557
+
558
+ /** Returns every stable column ID in current display order. @returns {string[]} */
559
+ getColumnOrder() {
560
+ return this._columns.map((column) => column.id);
561
+ }
562
+
563
+ /**
564
+ * Reconciles a partial or stale preferred order with every current column.
565
+ * @param {string[]} order Preferred stable IDs.
566
+ * @param {{silent?:boolean}} [options={}] Event behavior.
567
+ * @returns {this}
568
+ * @fires Table#columnorderchange
569
+ * @fires Table#editcancel
570
+ */
571
+ setColumnOrder(order, options = {}) {
572
+ const nextOrder = reconcileTableColumnOrder(this._columns, order);
573
+ if (sameArray(nextOrder, this.getColumnOrder())) return this;
574
+ const focus = this._activeColumnControl();
575
+ const byId = new Map(this._columns.map((column) => [column.id, column]));
576
+ this._abortEdit({ focus: false });
577
+ this._columns = nextOrder.map((id) => /** @type {TableColumn} */ (byId.get(id)));
578
+ this._hierarchy = normalizeHierarchy(this.options.hierarchy, this._columns);
579
+ this._renderColumns();
580
+ this._renderHeader();
581
+ this._renderBody();
582
+ this._renderColumnControls(focus);
583
+ if (!options.silent) this.emit('columnorderchange', { order: this.getColumnOrder() });
584
+ return this;
585
+ }
586
+
587
+ /**
588
+ * Moves one column to an edge of another column. Unknown IDs are a safe no-op.
589
+ * @param {string} id Moving stable ID.
590
+ * @param {string} targetId Target stable ID.
591
+ * @param {'before'|'after'} [position='before'] Target edge.
592
+ * @param {{silent?:boolean}} [options={}] Event behavior.
593
+ * @returns {this}
594
+ */
595
+ moveColumn(id, targetId, position = 'before', options = {}) {
596
+ if (position !== 'before' && position !== 'after') {
597
+ throw new TypeError('Table column position must be "before" or "after"');
598
+ }
599
+ const next = moveTableColumn(this.getColumnOrder(), String(id), String(targetId), position);
600
+ if (sameArray(next, this.getColumnOrder())) return this;
601
+ this.setColumnOrder(next, options);
602
+ if (this._columnStatus) {
603
+ const column = this._columns.find((candidate) => candidate.id === String(id));
604
+ const index = this.getColumnOrder().indexOf(String(id));
605
+ if (column && index >= 0) {
606
+ this._columnStatus.textContent = `Moved ${column.label} to position ${index + 1} of ${this._columns.length}.`;
607
+ }
608
+ }
609
+ return this;
610
+ }
611
+
499
612
  /** Returns visible column IDs in caller order. @returns {string[]} */
500
613
  getVisibleColumns() {
501
614
  return this._visibleColumns().map((column) => column.id);
@@ -514,17 +627,16 @@ export class Table extends Component {
514
627
  * @returns {this}
515
628
  */
516
629
  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) {
630
+ const columnId = String(id);
631
+ const column = this._columns.find((candidate) => candidate.id === columnId);
632
+ if (!column) throw new RangeError(`Unknown table column: ${columnId}`);
633
+ const restoreFocus = this._activeColumnControl();
634
+ if (!visible && !this._hiddenColumns.has(columnId) && this._visibleColumns().length <= 1) {
523
635
  this._renderColumnControls(restoreFocus);
524
636
  return this;
525
637
  }
526
- const changed = visible ? this._hiddenColumns.delete(id) : !this._hiddenColumns.has(id);
527
- if (!visible && changed) this._hiddenColumns.add(id);
638
+ const changed = visible ? this._hiddenColumns.delete(columnId) : !this._hiddenColumns.has(columnId);
639
+ if (!visible && changed) this._hiddenColumns.add(columnId);
528
640
  if (!changed) return this;
529
641
  this._renderColumns();
530
642
  this._renderHeader();
@@ -538,7 +650,7 @@ export class Table extends Component {
538
650
 
539
651
  /** Toggles one column. @param {string} id @param {{silent?:boolean}} [options={}] @returns {this} */
540
652
  toggleColumn(id, options = {}) {
541
- return this.setColumnVisible(id, this._hiddenColumns.has(id), options);
653
+ return this.setColumnVisible(id, this._hiddenColumns.has(String(id)), options);
542
654
  }
543
655
 
544
656
  /**
@@ -619,6 +731,28 @@ export class Table extends Component {
619
731
  return this;
620
732
  }
621
733
 
734
+ /** Returns a defensive copy of the active sort. @returns {{id:string,dir:TableSortDirection}|null} */
735
+ getSort() {
736
+ return this._sort ? { ...this._sort } : null;
737
+ }
738
+
739
+ /**
740
+ * Clears the active sort indicator without attempting to reconstruct the caller's original row
741
+ * order. The current display order becomes the unsorted order until data is replaced.
742
+ * @param {{silent?:boolean}} [options={}] Event behavior.
743
+ * @returns {this}
744
+ * @fires Table#sort
745
+ * @fires Table#editcancel
746
+ */
747
+ clearSort(options = {}) {
748
+ if (!this._sort) return this;
749
+ this._abortEdit({ focus: false });
750
+ this._sort = null;
751
+ this._syncSortHeader();
752
+ if (!options.silent) this.emit('sort', { id: null, dir: null });
753
+ return this;
754
+ }
755
+
622
756
  /**
623
757
  * Returns selected rows in current display order.
624
758
  * @returns {TableRow[]} Selected rows.
@@ -1075,6 +1209,9 @@ export class Table extends Component {
1075
1209
  _createColumnControls() {
1076
1210
  const list = h('div', { class: 'zx-table__column-list' });
1077
1211
  this._columnList = list;
1212
+ this._columnStatus = h('div', {
1213
+ class: 'zx-table__column-status', role: 'status', ariaLive: 'polite'
1214
+ });
1078
1215
  const details = h('details', { class: 'zx-table__column-controls' },
1079
1216
  h('summary', { class: 'zx-button zx-button--sm' }, icon('fields', { size: 13 }), 'Columns'),
1080
1217
  list);
@@ -1082,40 +1219,119 @@ export class Table extends Component {
1082
1219
  return details;
1083
1220
  }
1084
1221
 
1085
- /** @param {string|null} [focusColumn=null] Column checkbox that keeps focus. @returns {void} */
1086
- _renderColumnControls(focusColumn = null) {
1222
+ /**
1223
+ * @param {{id:string,action:string}|null} [focusControl=null] Control that keeps focus.
1224
+ * @returns {void}
1225
+ */
1226
+ _renderColumnControls(focusControl = null) {
1087
1227
  if (!this._columnList) return;
1088
1228
  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) => {
1229
+ const controls = this._columns.map((column, index) => {
1102
1230
  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);
1231
+ let controlId = this._columnControlIds.get(column.id);
1232
+ if (!controlId) {
1233
+ controlId = uid('zx-table-column');
1234
+ this._columnControlIds.set(column.id, controlId);
1235
+ }
1236
+ const toggle = this.options.columnVisibility ? h('input', {
1237
+ id: controlId,
1238
+ class: 'zx-table__column-toggle', type: 'checkbox', value: column.id, checked,
1239
+ disabled: checked && visible.length === 1,
1240
+ dataset: { columnId: column.id, columnAction: 'toggle' }
1241
+ }) : null;
1242
+ const label = toggle
1243
+ ? h('label', { for: controlId }, toggle, h('span', {}, column.label))
1244
+ : h('span', { class: 'zx-table__column-label' }, column.label);
1245
+ const order = this.options.columnReorder ? h('span', {
1246
+ class: 'zx-table__column-order', role: 'group', ariaLabel: `Move ${column.label}`
1247
+ },
1248
+ h('button', {
1249
+ class: 'zx-table__column-move', type: 'button', disabled: index === 0,
1250
+ ariaLabel: `Move ${column.label} earlier`,
1251
+ dataset: { columnId: column.id, columnAction: 'up' }
1252
+ }, icon('chevron-up', { size: 11 })),
1253
+ h('button', {
1254
+ class: 'zx-table__column-move', type: 'button', disabled: index === this._columns.length - 1,
1255
+ ariaLabel: `Move ${column.label} later`,
1256
+ dataset: { columnId: column.id, columnAction: 'down' }
1257
+ }, icon('chevron-down', { size: 11 }))) : null;
1258
+ return h('div', { class: 'zx-table__column-option', dataset: { columnId: column.id } }, label, order);
1259
+ });
1260
+ this._columnList.replaceChildren(...controls, this._columnStatus);
1261
+ if (focusControl !== null) {
1262
+ let control = [...this._columnList.querySelectorAll('[data-column-action]')]
1263
+ .find((candidate) => {
1264
+ const element = /** @type {HTMLElement} */ (candidate);
1265
+ return element.dataset.columnId === focusControl.id
1266
+ && element.dataset.columnAction === focusControl.action;
1267
+ });
1268
+ if (/** @type {HTMLButtonElement|HTMLInputElement|undefined} */ (control)?.disabled) {
1269
+ control = [...this._columnList.querySelectorAll('.zx-table__column-move[data-column-action]')]
1270
+ .find((candidate) => {
1271
+ const element = /** @type {HTMLButtonElement|HTMLInputElement} */ (candidate);
1272
+ return element.dataset.columnId === focusControl.id && !element.disabled;
1273
+ }) ?? [...this._columnList.querySelectorAll('[data-column-action]')]
1274
+ .find((candidate) => {
1275
+ const element = /** @type {HTMLButtonElement|HTMLInputElement} */ (candidate);
1276
+ return element.dataset.columnId === focusControl.id && !element.disabled;
1277
+ });
1278
+ }
1113
1279
  queueMicrotask(() => {
1114
1280
  if (control?.isConnected) /** @type {HTMLElement} */ (control).focus();
1115
1281
  });
1116
1282
  }
1117
1283
  }
1118
1284
 
1285
+ /** @returns {{id:string,action:string}|null} */
1286
+ _activeColumnControl() {
1287
+ const active = typeof document === 'undefined' ? null : /** @type {HTMLElement|null} */ (document.activeElement);
1288
+ if (!active || !this._columnList?.contains(active)) return null;
1289
+ const id = active.dataset.columnId;
1290
+ const action = active.dataset.columnAction;
1291
+ return id && action ? { id, action } : null;
1292
+ }
1293
+
1294
+ /** @param {Event} event @returns {void} */
1295
+ _handleColumnControlClick(event) {
1296
+ const button = /** @type {Element|null} */ (event.target)?.closest?.('.zx-table__column-move');
1297
+ if (!button || !this._columnList?.contains(button)) return;
1298
+ const id = /** @type {HTMLElement} */ (button).dataset.columnId;
1299
+ const action = /** @type {HTMLElement} */ (button).dataset.columnAction;
1300
+ const index = this.getColumnOrder().indexOf(String(id));
1301
+ if (action === 'up' && index > 0) this.moveColumn(String(id), this._columns[index - 1].id, 'before');
1302
+ if (action === 'down' && index >= 0 && index < this._columns.length - 1) {
1303
+ this.moveColumn(String(id), this._columns[index + 1].id, 'after');
1304
+ }
1305
+ }
1306
+
1307
+ /**
1308
+ * Adds Arrow/Home/End movement to the reorder buttons; Enter and Space retain native button
1309
+ * activation. Focus follows the stable column id across the cancel-safe row/header rebuild.
1310
+ * @param {KeyboardEvent} event Keyboard event.
1311
+ * @returns {void}
1312
+ */
1313
+ _handleColumnControlKeydown(event) {
1314
+ const control = /** @type {Element|null} */ (event.target)?.closest?.('.zx-table__column-move');
1315
+ if (!control || !this._columnList?.contains(control)) return;
1316
+ const id = String(/** @type {HTMLElement} */ (control).dataset.columnId);
1317
+ const order = this.getColumnOrder();
1318
+ const index = order.indexOf(id);
1319
+ if (index < 0) return;
1320
+ if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
1321
+ event.preventDefault();
1322
+ if (index > 0) this.moveColumn(id, order[index - 1], 'before');
1323
+ } else if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
1324
+ event.preventDefault();
1325
+ if (index < order.length - 1) this.moveColumn(id, order[index + 1], 'after');
1326
+ } else if (event.key === 'Home') {
1327
+ event.preventDefault();
1328
+ if (index > 0) this.moveColumn(id, order[0], 'before');
1329
+ } else if (event.key === 'End') {
1330
+ event.preventDefault();
1331
+ if (index < order.length - 1) this.moveColumn(id, order.at(-1), 'after');
1332
+ }
1333
+ }
1334
+
1119
1335
  /** @param {DragEvent} event @returns {void} */
1120
1336
  _handleRowDragStart(event) {
1121
1337
  const handle = /** @type {Element|null} */ (event.target)?.closest?.('.zx-table__row-handle');
@@ -2091,6 +2307,9 @@ export class Table extends Component {
2091
2307
  /** @event Table#rowtoggle @type {CustomEvent<TableRowToggleDetail>} */
2092
2308
  /** @event Table#stackedchange @type {CustomEvent<{stacked: boolean}>} */
2093
2309
  /** @event Table#grow @type {CustomEvent<{rendered: number, total: number}>} */
2310
+ /** @event Table#rowmove @type {CustomEvent<TableRowMoveDetail>} */
2311
+ /** @event Table#columnvisibilitychange @type {CustomEvent<{visible:string[],hidden:string[]}>} */
2312
+ /** @event Table#columnorderchange @type {CustomEvent<{order:string[]}>} */
2094
2313
 
2095
2314
  /**
2096
2315
  * Editor opened for a cell (or, in row mode, for the row).
@@ -2541,6 +2760,67 @@ function deferToEditor(target) {
2541
2760
  return target.getAttribute?.('aria-expanded') === 'true';
2542
2761
  }
2543
2762
 
2763
+ /**
2764
+ * Validates and shallow-clones table columns so stable-id APIs never mutate caller descriptors.
2765
+ * @param {unknown} columns Column descriptors.
2766
+ * @returns {TableColumn[]} Normalized descriptors.
2767
+ */
2768
+ export function normalizeTableColumns(columns) {
2769
+ if (!Array.isArray(columns)) throw new TypeError('Table columns must be an array');
2770
+ const seen = new Set();
2771
+ return columns.map((column, index) => {
2772
+ if (!column || typeof column !== 'object' || Array.isArray(column)) {
2773
+ throw new TypeError(`Table column at index ${index} must be an object`);
2774
+ }
2775
+ const id = String(column.id ?? '').trim();
2776
+ if (!id) throw new TypeError(`Table column at index ${index} requires an id`);
2777
+ if (seen.has(id)) throw new TypeError(`Duplicate table column: ${id}`);
2778
+ seen.add(id);
2779
+ return { ...column, id, label: String(column.label ?? id) };
2780
+ });
2781
+ }
2782
+
2783
+ /**
2784
+ * Reconciles saved or partial column IDs with every current column.
2785
+ * @param {TableColumn[]} columns Current columns.
2786
+ * @param {unknown} order Preferred IDs.
2787
+ * @returns {string[]} Complete stable order.
2788
+ */
2789
+ export function reconcileTableColumnOrder(columns, order) {
2790
+ const available = columns.map((column) => column.id);
2791
+ const allowed = new Set(available);
2792
+ const preferred = Array.isArray(order) ? order.map(String) : [];
2793
+ const included = new Set();
2794
+ const result = [];
2795
+ for (const id of preferred) {
2796
+ if (allowed.has(id) && !included.has(id)) {
2797
+ included.add(id);
2798
+ result.push(id);
2799
+ }
2800
+ }
2801
+ for (const id of available) if (!included.has(id)) result.push(id);
2802
+ return result;
2803
+ }
2804
+
2805
+ /**
2806
+ * Moves one stable column ID without changing the supplied order.
2807
+ * @param {string[]} order Current IDs.
2808
+ * @param {string} id Moving ID.
2809
+ * @param {string} targetId Target ID.
2810
+ * @param {'before'|'after'} [position='before'] Target edge.
2811
+ * @returns {string[]} Reordered copy, or an unchanged copy for unknown/same IDs.
2812
+ */
2813
+ export function moveTableColumn(order, id, targetId, position = 'before') {
2814
+ const next = [...order];
2815
+ const from = next.indexOf(id);
2816
+ const target = next.indexOf(targetId);
2817
+ if (from < 0 || target < 0 || from === target) return next;
2818
+ next.splice(from, 1);
2819
+ const insertion = next.indexOf(targetId) + (position === 'after' ? 1 : 0);
2820
+ next.splice(insertion, 0, id);
2821
+ return next;
2822
+ }
2823
+
2544
2824
  /** @param {HTMLTableRowElement} tr @param {string} columnId @returns {HTMLTableCellElement|null} */
2545
2825
  function cellIn(tr, columnId) {
2546
2826
  for (const cell of tr.cells) {
@@ -2573,6 +2853,11 @@ function sameSet(left, right) {
2573
2853
  return left.size === right.size && [...left].every((value) => right.has(value));
2574
2854
  }
2575
2855
 
2856
+ /** @param {unknown[]} left @param {unknown[]} right @returns {boolean} */
2857
+ function sameArray(left, right) {
2858
+ return left.length === right.length && left.every((value, index) => Object.is(value, right[index]));
2859
+ }
2860
+
2576
2861
  /**
2577
2862
  * Normalizes the `growing` option into a batch size.
2578
2863
  * @param {unknown} growing Option value.
@@ -0,0 +1,18 @@
1
+ .zx-table-view {
2
+ min-inline-size: 0;
3
+ }
4
+
5
+ .zx-table-view__table {
6
+ min-inline-size: 0;
7
+ }
8
+
9
+ .zx-table-view[data-loading="true"] .zx-table__column-controls {
10
+ pointer-events: none;
11
+ opacity: .62;
12
+ }
13
+
14
+ @media (prefers-reduced-motion: no-preference) {
15
+ .zx-table-view .zx-table__column-controls {
16
+ transition: opacity var(--zx-dur-fast) var(--zx-ease);
17
+ }
18
+ }