flexdesk 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -388,6 +388,17 @@ const AUTOSIZE_MAX_PX = 520;
388
388
  * @property {boolean|number} [keepWhileDetached=false] - With `autoDispose`: how long a table taken out of the page may stay out and still come back drawn. A number is a grace in ms; `true` keeps it for as long as it is out (it is disposed only by `dispose()`, and what it holds meanwhile is its own DOM — nothing on the document, and a follow timer that holds it weakly). A table put back within its grace is not disposed, however many tasks it was out.
389
389
  * @property {Object|Array|Function} [filterKinds] - FILTER CONTROLS THAT ARE NOT TEXT BOXES, per column (an object or array keyed by column index, or `(colIdx, header) => spec`). A spec is `'text'` (the 0.4 box), `{kind: 'chips', options: [{value, label, icon?, title?}], multiple?}` (one-of-N chips with counts — an option whose value is `''` or null means "no filter"), or `{create(ctx)}` / a function `create(ctx)` for the consumer's own control (a date range, a person): `ctx` is `{column, header, value, counts, setValue(v), table}`, and it returns `{el, setValue?(v), setCounts?(c), destroy?(), match?(cellValue, filterValue, row)}`. A filter's value may then be anything (an array, an object): `query.filters` forwards it as it is, and on the client the control's `match` decides (chips: equality, or membership for `multiple`). The control is built once and kept across renders, and is in the column-width measure: a column is never narrower than its control at its narrowest (the widest chip), and the cell wraps the rest.
390
390
  * @property {Function} [onFilterChange] - `(filters) => void` with `[{column, header, value}]`, whenever a column filter changes.
391
+ *
392
+ * ── STORY-0010, every one of them off unless asked for ─────────────────────
393
+ * @property {Array|Function} [contextMenuItems] - The consumer's own items in the table's right-click menu: an array, or `(row, rowIdx, ctx) => items`, asked on every right-click of a body row so the items can follow the row and the live selection. An item is `{label, icon?, action, disabled?, danger?, title?}` or `{separator: true}`, drawn as the copy items are (`disabled` greys it, `danger` is `twm-delete-node`). `ctx` is `{rowIdx, row, colIdx, value, selection, selectedRows}`: the right-clicked row (already selected by then), the right-clicked cell (`colIdx` null off a data column, the row-number gutter included), and the selection's original indices with their rows, 1:1. The items come first; with `copyable` the copy items follow behind a separator. Set, it installs the menu without `copyable` too.
394
+ * @property {Function} [onContextMenuAction] - `(action, rowIdx, row, ctx) => void`, when one of `contextMenuItems` is chosen; the menu is hidden first. The copy items never come here.
395
+ * @property {Function} [onRender] - `(table) => void`, at the end of every `render()` — for a consumer that shows something the rows decide (a count) outside the table. See `getDisplayedRows`.
396
+ * @property {boolean} [keyboardNav=false] - On the focused table, ArrowUp/ArrowDown move the selection one row through the rows of the page shown (in the order shown), Home/End to its first and last, and with Shift (a table that selects several rows) select the range from the anchor, as Shift-click does. The row moved to is scrolled into view (`scrollToRow`) and `onSelectionChange` is told. Needs `selectable`. Not on a key pressed in a control inside the table, nor with Ctrl, Cmd or Alt.
397
+ * @property {Function} [onRowKeyDown] - `(rowIdx, row, ev) => boolean|void`, for every key pressed on the focused table (not in a control inside it) BEFORE the table's own keys: `rowIdx`/`row` are the row Enter would open (the one row selected, the selection's anchor, else the active row — see `activateOn`), or null when none is drawn. Return `true` and the key is the consumer's: the table does nothing more with it (default prevented, propagation stopped — the page's keymap does not see it either). For the keys `activateOn` does not take: Ctrl+Enter, Alt+T, a "delete this row".
398
+ * @property {number[]} [hiddenColumns] - STORY-0012. Columns (data indices) NOT DRAWN: no header, no filter box, no cell, no width in the fit, not in a copy or the CSV. They stay in the row data — `renderCell`, `contextMenuItems`' `ctx.row`, sorting and filtering (`sortBy`, `setFilter`) all still see them. A column sorted on while hidden keeps the order and has no arrow to show it. The last column shown cannot be hidden. With `persistKey` the set shown is saved, and a saved set wins over this one. `setData({hiddenColumns})` applies a new set.
399
+ * @property {boolean} [columnChooser=false] - STORY-0012. A right-click on the header lists every column with a check mark on those shown; choosing one shows or hides it (`setColumnVisible`).
400
+ * @property {Function} [onColumnsChange] - STORY-0012. `(hidden) => void` with the hidden columns' indices, ascending, after every change of the set (the chooser, `setColumnVisible`, `setData({hiddenColumns})`).
401
+ * @property {boolean} [persistPage=false] - With `persistKey`: the client page is saved with the sort and the filters, and opened again on mount (on the last page, if the rows have shrunk since). OFF by default: a key shared by DIFFERENT row sets (ad-hoc filters, search results) must not reopen on a page of the last list. A sort under `resetPageOnSort` and a new filter save the first page, as they show it. A server's page (`onPageChange`, `dataSource`) is the consumer's and is not kept.
391
402
  */
392
403
 
393
404
  /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
@@ -395,6 +406,19 @@ const AUTOSIZE_MAX_PX = 520;
395
406
  * it were this tall. A ResizeObserver corrects it the moment a real size exists. */
396
407
  const VIRTUAL_FALLBACK_VIEWPORT_PX = 1000;
397
408
 
409
+ /** The copy menu's two items — the whole right-click menu of a table without
410
+ * `contextMenuItems`, and its tail with them (STORY-0010). */
411
+ const COPY_MENU_HTML = `
412
+ <div class="twm-context-menu-item" data-action="copy-tsv">
413
+ <span class="material-symbols-outlined">content_copy</span>
414
+ <span class="label">Copy Row(s)</span>
415
+ </div>
416
+ <div class="twm-context-menu-item" data-action="copy-csv">
417
+ <span class="material-symbols-outlined">table</span>
418
+ <span class="label">Copy as CSV</span>
419
+ </div>
420
+ `;
421
+
398
422
  export class DataTable {
399
423
  /**
400
424
  * Create a DataTable instance
@@ -507,6 +531,18 @@ export class DataTable {
507
531
  keepWhileDetached: false,
508
532
  filterKinds: null,
509
533
  onFilterChange: null,
534
+ // STORY-0010. Off — or exactly the 0.9.1 behaviour — until set. The
535
+ // typedef says what each does.
536
+ contextMenuItems: null,
537
+ onContextMenuAction: null,
538
+ onRender: null,
539
+ keyboardNav: false,
540
+ onRowKeyDown: null,
541
+ persistPage: false,
542
+ // STORY-0012. Off — every column drawn, as in 0.9.1 — until set.
543
+ hiddenColumns: null,
544
+ columnChooser: false,
545
+ onColumnsChange: null,
510
546
  ...config,
511
547
  };
512
548
 
@@ -522,6 +558,11 @@ export class DataTable {
522
558
  this._filterCounts = new Map();
523
559
  this._pendingFilter = null;
524
560
 
561
+ // STORY-0012. The columns not drawn, by data index. Before the restore
562
+ // below, which may replace them with the remembered set.
563
+ this._hidden = new Set();
564
+ this._setHidden(this.config.hiddenColumns);
565
+
525
566
  // 0.5.0. The open row's key, and the failure on show (`setError`).
526
567
  this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
527
568
  this._error = null;
@@ -563,6 +604,11 @@ export class DataTable {
563
604
  this._tableEl = null;
564
605
  this._tbodyEl = null;
565
606
  this._contextMenuEl = null;
607
+ // STORY-0010. The right-click the custom items on show were built for,
608
+ // read again when one is chosen.
609
+ this._contextMenuCtx = null;
610
+ // STORY-0010 `keyboardNav`: the row the arrows last moved to.
611
+ this._navCursor = null;
566
612
  this._filterDropdownEl = null;
567
613
  this._filterDropdownCleanup = null;
568
614
 
@@ -649,6 +695,12 @@ export class DataTable {
649
695
  this._state.filters = new Map(blob.filters);
650
696
  applied = true;
651
697
  }
698
+ // STORY-0012. The columns shown, before the widths: the widths are by
699
+ // DRAWN column, and were saved for this set.
700
+ if (Array.isArray(blob.hidden)) {
701
+ this._setHidden(blob.hidden);
702
+ applied = true;
703
+ }
652
704
  if (blob.colWidths && typeof blob.colWidths === 'object') {
653
705
  // Stored under string keys (JSON) → coerce back to ints.
654
706
  this._colWidths = {};
@@ -660,6 +712,14 @@ export class DataTable {
660
712
  this._colWidthsSig = this._colSig();
661
713
  applied = true;
662
714
  }
715
+ // STORY-0010 `persistPage`. The page you were on — only when asked for:
716
+ // page 3 of one search is not a place in the next. Clamped by the first
717
+ // render that has rows, since they may have shrunk since it was saved.
718
+ if (this.config.persistPage && Number.isInteger(blob.offset) && blob.offset >= 0) {
719
+ this._state.offset = blob.offset;
720
+ this._clampRestoredPage = true;
721
+ applied = true;
722
+ }
663
723
  return applied;
664
724
  }
665
725
 
@@ -667,7 +727,10 @@ export class DataTable {
667
727
  * keyed by position, so a schema change invalidates them. */
668
728
  _colSig() {
669
729
  return (this.config.headers || []).join('\x01')
670
- + (this.config.showRowNumbers ? '|#' : '');
730
+ + (this.config.showRowNumbers ? '|#' : '')
731
+ // STORY-0012. Widths are by drawn column, so a different set drawn
732
+ // is a different schema — unless `setColumnVisible` carried them.
733
+ + (this._hidden?.size ? `|-${this.getHiddenColumns().join(',')}` : '');
671
734
  }
672
735
 
673
736
  /** Snapshot the persistable slice of state to the project store.
@@ -679,9 +742,146 @@ export class DataTable {
679
742
  sortAscending: this._state.sortAscending,
680
743
  filters: [...this._state.filters.entries()],
681
744
  colWidths: { ...this._colWidths },
745
+ // STORY-0010. Only for a table that asked: one that did not writes
746
+ // the 0.9.1 blob, key for key.
747
+ ...(this.config.persistPage ? { offset: this._state.offset } : {}),
748
+ // STORY-0012. Only for a table that hides columns or lets them be
749
+ // hidden.
750
+ ...(this._columnsOn() ? { hidden: this.getHiddenColumns() } : {}),
682
751
  });
683
752
  }
684
753
 
754
+ // ─────────────────────────────────────────────────────────────────
755
+ // STORY-0012 — columns shown and hidden
756
+ // ─────────────────────────────────────────────────────────────────
757
+
758
+ /** Whether this table hides columns at all (and so saves the set). */
759
+ _columnsOn() {
760
+ return Array.isArray(this.config.hiddenColumns) || !!this.config.columnChooser
761
+ || !!this._columnsTouched;
762
+ }
763
+
764
+ /** Replace the hidden set: whole indices of existing columns, and never
765
+ * every column — the first one stays when nothing else would. A table
766
+ * with no headers yet keeps the set for the headers `setData` brings. */
767
+ _setHidden(list) {
768
+ const n = (this.config.headers || []).length;
769
+ this._hidden = new Set((Array.isArray(list) ? list : [])
770
+ .map(Number).filter((c) => Number.isInteger(c) && c >= 0 && (!n || c < n)));
771
+ if (n && this._hidden.size >= n) this._hidden.delete(0);
772
+ }
773
+
774
+ _isHidden(colIdx) {
775
+ return this._hidden.has(colIdx);
776
+ }
777
+
778
+ /** The cells a drawn row has: the shown columns, and the row number. */
779
+ _domColCount() {
780
+ const n = Math.max(0, (this.config.headers?.length || 0) - this._hidden.size);
781
+ return n + (this.config.showRowNumbers ? 1 : 0);
782
+ }
783
+
784
+ /** A data column's drawn index (row number included), or -1 when hidden. */
785
+ _domIdxOf(colIdx) {
786
+ if (this._isHidden(colIdx)) return -1;
787
+ let dom = this.config.showRowNumbers ? 1 : 0;
788
+ for (const h of this._hidden) if (h < colIdx) dom--;
789
+ return dom + colIdx;
790
+ }
791
+
792
+ /** A drawn cell's data column, or null for the row number. */
793
+ _dataIdxOfDom(domIdx) {
794
+ let i = domIdx - (this.config.showRowNumbers ? 1 : 0);
795
+ if (i < 0) return null;
796
+ const n = this.config.headers?.length || 0;
797
+ for (let c = 0; c < n; c++) {
798
+ if (this._isHidden(c)) continue;
799
+ if (i === 0) return c;
800
+ i--;
801
+ }
802
+ return null;
803
+ }
804
+
805
+ /** The columns not drawn, by data index, ascending. */
806
+ getHiddenColumns() {
807
+ return [...(this._hidden || [])].sort((a, b) => a - b);
808
+ }
809
+
810
+ /**
811
+ * Show (`on` true) or hide a column. The last column shown is not hidden.
812
+ * The widths of the other columns stay as they were; a column shown again
813
+ * is measured. Saved under `persistKey`, and `onColumnsChange` is told.
814
+ *
815
+ * @param {number} colIdx
816
+ * @param {boolean} [on=true]
817
+ * @returns {boolean} whether the set changed
818
+ */
819
+ setColumnVisible(colIdx, on = true) {
820
+ const n = this.config.headers?.length || 0;
821
+ if (!Number.isInteger(colIdx) || colIdx < 0 || colIdx >= n) return false;
822
+ if (!!on === !this._isHidden(colIdx)) return false;
823
+ if (!on && n - this._hidden.size <= 1) return false;
824
+ this._columnsTouched = true;
825
+ const next = new Set(this._hidden);
826
+ if (on) next.delete(colIdx); else next.add(colIdx);
827
+ this._applyHidden(next);
828
+ return true;
829
+ }
830
+
831
+ /** Swap in a new hidden set, carrying the drawn widths of the columns
832
+ * that stay drawn to their new places, and tell whoever listens. */
833
+ _applyHidden(next) {
834
+ const before = this.getHiddenColumns();
835
+ const n = this.config.headers?.length || 0;
836
+ const old = this._colWidths;
837
+ const kept = this._colWidthsSig === this._colSig();
838
+ const domBefore = [];
839
+ for (let c = 0; c < n; c++) domBefore[c] = this._domIdxOf(c);
840
+ this._setHidden([...next]);
841
+ if (kept) {
842
+ const widths = {};
843
+ if (this.config.showRowNumbers && old[0] != null) widths[0] = old[0];
844
+ for (let c = 0; c < n; c++) {
845
+ const from = domBefore[c];
846
+ const to = this._domIdxOf(c);
847
+ if (from >= 0 && to >= 0 && old[from] != null) widths[to] = old[from];
848
+ }
849
+ this._colWidths = widths;
850
+ this._colWidthsSig = this._colSig();
851
+ }
852
+ const after = this.getHiddenColumns();
853
+ if (before.join() === after.join()) return;
854
+ this._savePersisted();
855
+ if (this._wrapperEl) this.render();
856
+ try { this.config.onColumnsChange?.(after); } catch (err) {
857
+ console.error('[DataTable] onColumnsChange threw', err);
858
+ }
859
+ }
860
+
861
+ /** `columnChooser`: the header's right-click menu, one checkable item a
862
+ * column. */
863
+ _showColumnChooser(clientX, clientY) {
864
+ const headers = this.config.headers || [];
865
+ const lastShown = headers.length - this._hidden.size <= 1;
866
+ const items = headers.map((h, c) => {
867
+ const shown = !this._isHidden(c);
868
+ return {
869
+ label: String(h ?? ''),
870
+ icon: shown ? 'check' : '',
871
+ checked: shown,
872
+ disabled: shown && lastShown,
873
+ title: shown && lastShown ? 'The last column shown cannot be hidden' : '',
874
+ onChoose: () => this.setColumnVisible(c, !shown),
875
+ };
876
+ });
877
+ this._showContextMenu(clientX, clientY, { ctx: null, items, copy: false });
878
+ }
879
+
880
+ /** STORY-0010 `persistPage`: the client page changed, so save it. */
881
+ _savePage() {
882
+ if (this.config.persistPage) this._savePersisted();
883
+ }
884
+
685
885
  /**
686
886
  * Update data and re-render
687
887
  * @param {Object} updates - Partial config updates (headers, rows, etc.)
@@ -713,6 +913,21 @@ export class DataTable {
713
913
 
714
914
  Object.assign(this.config, updates);
715
915
 
916
+ // STORY-0012. A new set asked for applies at once; new columns keep
917
+ // the set by position, minus any column that is gone.
918
+ if (Object.prototype.hasOwnProperty.call(updates, 'hiddenColumns')) {
919
+ const before = this.getHiddenColumns().join();
920
+ this._setHidden(updates.hiddenColumns);
921
+ if (this.getHiddenColumns().join() !== before) {
922
+ this._savePersisted();
923
+ try { this.config.onColumnsChange?.(this.getHiddenColumns()); } catch (err) {
924
+ console.error('[DataTable] onColumnsChange threw', err);
925
+ }
926
+ }
927
+ } else if (updates.headers && this._hidden.size) {
928
+ this._setHidden([...this._hidden]);
929
+ }
930
+
716
931
  // Reset state if rows changed
717
932
  if (updates.rows) {
718
933
  this._state.selected.clear();
@@ -909,6 +1124,7 @@ export class DataTable {
909
1124
  const rowCount = this._getProcessedRows().length;
910
1125
  const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
911
1126
  this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
1127
+ this._savePage();
912
1128
  this.render();
913
1129
  }
914
1130
 
@@ -925,6 +1141,7 @@ export class DataTable {
925
1141
  const rowCount = this._getProcessedRows().length;
926
1142
  const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
927
1143
  this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
1144
+ this._savePage();
928
1145
  this.render();
929
1146
  }
930
1147
 
@@ -1003,6 +1220,40 @@ export class DataTable {
1003
1220
  return this._getProcessedRows().length;
1004
1221
  }
1005
1222
 
1223
+ /**
1224
+ * STORY-0010. Give the table the keyboard focus (the body, where the arrow
1225
+ * keys and `onRowKeyDown` listen) without scrolling the page. False when
1226
+ * there is nothing drawn to focus.
1227
+ * @returns {boolean}
1228
+ */
1229
+ focus() {
1230
+ if (!this._tableEl) return false;
1231
+ try { this._tableEl.focus({ preventScroll: true }); } catch (_) { return false; }
1232
+ return true;
1233
+ }
1234
+
1235
+ /** STORY-0010 `keyboardNav`: the original indices of the page shown, in
1236
+ * the order shown — every row held, when the page is a server's or there
1237
+ * are no pages. */
1238
+ _pageOrder() {
1239
+ this._getProcessedRows();
1240
+ const order = this._processedIndexMap || [];
1241
+ if (this._isServerSide() || !this.config.pagination || this._pagingConfig()) return order;
1242
+ return order.slice(this._state.offset, this._state.offset + this.config.pageSize);
1243
+ }
1244
+
1245
+ /**
1246
+ * STORY-0010. The rows the table shows, as row arrays, in the order shown:
1247
+ * filtered and sorted, across EVERY page — not only the one on screen. With
1248
+ * server-side paging (`onPageChange`, `dataSource`) they are the page the
1249
+ * consumer or the source gave, which is all the table holds. A copy:
1250
+ * changing it changes nothing in the table.
1251
+ * @returns {Array<Array>}
1252
+ */
1253
+ getDisplayedRows() {
1254
+ return this._getProcessedRows().slice();
1255
+ }
1256
+
1006
1257
  /**
1007
1258
  * Render the table
1008
1259
  */
@@ -1054,6 +1305,20 @@ export class DataTable {
1054
1305
  this._columnTypes = this._detectColumnTypes();
1055
1306
  }
1056
1307
 
1308
+ // STORY-0010 `persistPage`. A restored page is put on a page boundary,
1309
+ // and past the rows (they shrank since it was saved, or a restored
1310
+ // filter narrowed them) it is the last page — by the first render that
1311
+ // has rows to count, which for a board that sets its rows after it
1312
+ // mounts is not the first render.
1313
+ if (this._clampRestoredPage && rows.length > 0) {
1314
+ this._clampRestoredPage = false;
1315
+ if (!this._isServerSide() && !this._pagingConfig()) {
1316
+ const size = this.config.pageSize;
1317
+ const last = Math.max(0, Math.ceil(this._getProcessedRows().length / size) - 1) * size;
1318
+ this._state.offset = Math.min(Math.floor(this._state.offset / size) * size, last);
1319
+ }
1320
+ }
1321
+
1057
1322
  // Create wrapper. Compact mode adds a modifier class that the
1058
1323
  // CSS uses to tighten padding + drop font size — opt-in so
1059
1324
  // existing landing/tab use sites stay identical.
@@ -1139,6 +1404,7 @@ export class DataTable {
1139
1404
  tr.appendChild(th);
1140
1405
  }
1141
1406
  headers.forEach((h, colIdx) => {
1407
+ if (this._isHidden(colIdx)) return;
1142
1408
  const th = document.createElement('th');
1143
1409
  th.className = this._columnTypes[colIdx] || 'text';
1144
1410
  th.textContent = h;
@@ -1157,7 +1423,7 @@ export class DataTable {
1157
1423
  const bodyTable = document.createElement('table');
1158
1424
  bodyTable.className = headerTable.className;
1159
1425
  const tbody = document.createElement('tbody');
1160
- tbody.appendChild(this._buildEmptyRow(headers.length + (showRowNumbers ? 1 : 0), emptyMessage));
1426
+ tbody.appendChild(this._buildEmptyRow(this._domColCount(), emptyMessage));
1161
1427
  bodyTable.appendChild(tbody);
1162
1428
  tableWrap.appendChild(bodyTable);
1163
1429
  this._wrapperEl.appendChild(tableWrap);
@@ -1189,14 +1455,24 @@ export class DataTable {
1189
1455
  // 0.7.0 `keepFilterRow`: the header and its filters, then the
1190
1456
  // empty (or failed) row the other branch would have drawn.
1191
1457
  if (keepFilterRow && this._tbodyEl) {
1192
- const n = this.config.headers.length + (this.config.showRowNumbers ? 1 : 0);
1193
- this._tbodyEl.appendChild(this._buildEmptyRow(n, emptyMessage));
1458
+ this._tbodyEl.appendChild(this._buildEmptyRow(this._domColCount(), emptyMessage));
1194
1459
  }
1195
1460
  tableWrap.appendChild(fullTable);
1196
1461
  this._wrapperEl.appendChild(tableWrap);
1197
1462
  }
1198
1463
 
1199
1464
  this._tableWrapEl = tableWrap;
1465
+ // STORY-0012 `columnChooser`: the header's right-click.
1466
+ if (this.config.columnChooser && this._headerTableEl) {
1467
+ const head = this._headerTableEl;
1468
+ const onHeadMenu = (ev) => {
1469
+ ev.preventDefault();
1470
+ ev.stopPropagation();
1471
+ setTimeout(() => this._showColumnChooser(ev.clientX, ev.clientY), 0);
1472
+ };
1473
+ head.addEventListener('contextmenu', onHeadMenu);
1474
+ this._disposers.push(() => head.removeEventListener('contextmenu', onHeadMenu));
1475
+ }
1200
1476
  // 0.7.0 `pagerPosition: 'bottom'`: under the rows.
1201
1477
  if (this._paginationEl && this.config.pagerPosition === 'bottom') {
1202
1478
  this._paginationEl.classList.add('twm-pagination-controls--bottom');
@@ -1288,7 +1564,8 @@ export class DataTable {
1288
1564
  // Install interactions (readonly only prevents editing, not selection/copy)
1289
1565
  if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
1290
1566
  || typeof this.config.onRowActivate === 'function'
1291
- || typeof this.config.rowDetail === 'function')) {
1567
+ || typeof this.config.rowDetail === 'function'
1568
+ || typeof this.config.onRowKeyDown === 'function')) {
1292
1569
  this._installInteractions();
1293
1570
  }
1294
1571
 
@@ -1318,6 +1595,13 @@ export class DataTable {
1318
1595
  }
1319
1596
  if (this._following && !this._followTimer) this._armFollow();
1320
1597
  }
1598
+
1599
+ // STORY-0010. Last, so the hook reads the table as drawn.
1600
+ if (typeof this.config.onRender === 'function') {
1601
+ try { this.config.onRender(this); } catch (err) {
1602
+ console.error('[DataTable] onRender threw', err);
1603
+ }
1604
+ }
1321
1605
  }
1322
1606
 
1323
1607
  /**
@@ -1332,12 +1616,14 @@ export class DataTable {
1332
1616
  }
1333
1617
 
1334
1618
  const { headers, rows } = this.config;
1335
- const matrix = [headers];
1619
+ // STORY-0012. The columns shown — all of them, unless some are hidden.
1620
+ const shown = (_, colIdx) => !this._isHidden(colIdx);
1621
+ const matrix = [headers.filter(shown)];
1336
1622
 
1337
1623
  for (const idx of indices) {
1338
1624
  const row = rows[idx];
1339
1625
  if (row) {
1340
- matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)));
1626
+ matrix.push(row.map((val, colIdx) => this._formatValue(val, colIdx)).filter(shown));
1341
1627
  }
1342
1628
  }
1343
1629
 
@@ -1376,11 +1662,12 @@ export class DataTable {
1376
1662
  return;
1377
1663
  }
1378
1664
 
1379
- const lines = [headers.join(',')];
1665
+ const shown = (_, colIdx) => !this._isHidden(colIdx);
1666
+ const lines = [headers.filter(shown).join(',')];
1380
1667
  for (const row of rows) {
1381
1668
  lines.push(row.map((val, colIdx) =>
1382
1669
  this._csvEscape(this._formatValue(val, colIdx))
1383
- ).join(','));
1670
+ ).filter(shown).join(','));
1384
1671
  }
1385
1672
 
1386
1673
  const csv = lines.join('\n');
@@ -1729,6 +2016,7 @@ export class DataTable {
1729
2016
  }
1730
2017
 
1731
2018
  headers.forEach((header, colIdx) => {
2019
+ if (this._isHidden(colIdx)) return;
1732
2020
  const th = document.createElement('th');
1733
2021
  th.className = 'data-table__filter-cell';
1734
2022
 
@@ -2142,7 +2430,7 @@ export class DataTable {
2142
2430
  * rebuilt its box: the words, and the debounce that will apply them. */
2143
2431
  _resumePendingFilter({ colIdx, value }) {
2144
2432
  const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
2145
- const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
2433
+ const cell = filterRow?.children[this._domIdxOf(colIdx)];
2146
2434
  const input = cell?.querySelector('.twm-data-table__filter-input');
2147
2435
  if (input && input.value !== value) {
2148
2436
  const focused = input.ownerDocument?.activeElement === input;
@@ -2163,13 +2451,13 @@ export class DataTable {
2163
2451
  if (!row) return null;
2164
2452
  const cells = Array.from(row.children);
2165
2453
  const idx = cells.indexOf(cell);
2166
- return this.config.showRowNumbers ? idx - 1 : idx;
2454
+ return this._hidden.size ? this._dataIdxOfDom(idx) : (this.config.showRowNumbers ? idx - 1 : idx);
2167
2455
  }
2168
2456
 
2169
2457
  _restoreFilterFocus(colIdx) {
2170
2458
  const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
2171
2459
  if (!filterRow) return;
2172
- const cellIdx = this.config.showRowNumbers ? colIdx + 1 : colIdx;
2460
+ const cellIdx = this._domIdxOf(colIdx);
2173
2461
  const cell = filterRow.children[cellIdx];
2174
2462
  const input = cell?.querySelector('.twm-data-table__filter-input');
2175
2463
  if (input) {
@@ -2312,6 +2600,7 @@ export class DataTable {
2312
2600
  this._awaitWithSpinner(onPageChange(newOffset, pageSize));
2313
2601
  } else {
2314
2602
  this._state.offset = newOffset;
2603
+ this._savePage();
2315
2604
  this.render();
2316
2605
  }
2317
2606
  };
@@ -2732,6 +3021,7 @@ export class DataTable {
2732
3021
  }
2733
3022
 
2734
3023
  headers.forEach((header, colIdx) => {
3024
+ if (this._isHidden(colIdx)) return;
2735
3025
  const th = document.createElement('th');
2736
3026
  th.className = this._columnTypes[colIdx] || 'text';
2737
3027
 
@@ -2931,7 +3221,7 @@ export class DataTable {
2931
3221
  * any width already pinned for it, so a re-render starts at the widths it
2932
3222
  * had rather than at a content-sized frame that then snaps. */
2933
3223
  _createColgroup() {
2934
- const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
3224
+ const n = this._domColCount();
2935
3225
  const colgroup = document.createElement('colgroup');
2936
3226
  for (let i = 0; i < n; i++) {
2937
3227
  const col = document.createElement('col');
@@ -2955,8 +3245,7 @@ export class DataTable {
2955
3245
  tr.className = `twm-dt-spacer twm-dt-spacer--${edge}`;
2956
3246
  tr.setAttribute('aria-hidden', 'true');
2957
3247
  const td = document.createElement('td');
2958
- td.colSpan = Math.max(1, (this.config.headers?.length || 0)
2959
- + (this.config.showRowNumbers ? 1 : 0));
3248
+ td.colSpan = Math.max(1, this._domColCount());
2960
3249
  // INLINE, because it must beat every consumer's cell padding and
2961
3250
  // border at any specificity: a spacer that is 1px taller than its
2962
3251
  // arithmetic moves every row below it by that pixel.
@@ -3375,6 +3664,7 @@ export class DataTable {
3375
3664
  ? (diff.cells instanceof Set ? diff.cells : new Set(diff.cells))
3376
3665
  : null;
3377
3666
  for (let colIdx = 0; colIdx < row.length; colIdx++) {
3667
+ if (this._isHidden(colIdx)) continue;
3378
3668
  const td = document.createElement('td');
3379
3669
  td.className = this._columnTypes[colIdx] || 'text';
3380
3670
 
@@ -3615,7 +3905,7 @@ export class DataTable {
3615
3905
  }
3616
3906
 
3617
3907
  _buildDetailPieces(tr, key) {
3618
- const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
3908
+ const n = this._domColCount();
3619
3909
  const detailTr = document.createElement('tr');
3620
3910
  detailTr.className = 'twm-dt-detail-row';
3621
3911
  const td = document.createElement('td');
@@ -4381,6 +4671,7 @@ export class DataTable {
4381
4671
  if (source) { this._sourceGoTo(newOffset); return; }
4382
4672
  if (server) { this._awaitWithSpinner(c.onPageChange(newOffset, pageSize)); return; }
4383
4673
  this._state.offset = newOffset;
4674
+ this._savePage();
4384
4675
  this.render();
4385
4676
  };
4386
4677
 
@@ -4573,7 +4864,7 @@ export class DataTable {
4573
4864
  const v = textBox && value != null && typeof value !== 'string' ? String(value) : value;
4574
4865
  if (textBox) {
4575
4866
  const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
4576
- const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
4867
+ const cell = filterRow?.children[this._domIdxOf(colIdx)];
4577
4868
  const input = cell?.querySelector('.twm-data-table__filter-input');
4578
4869
  if (input) input.value = v || '';
4579
4870
  }
@@ -5868,6 +6159,8 @@ export class DataTable {
5868
6159
  if (!this._tbodyEl || !this._tableEl) return;
5869
6160
 
5870
6161
  const { selectable, copyable } = this.config;
6162
+ // STORY-0010. The consumer's own items install the menu by themselves.
6163
+ const customMenu = this.config.contextMenuItems != null;
5871
6164
  const tbody = this._tbodyEl;
5872
6165
  const table = this._tableEl;
5873
6166
 
@@ -5911,6 +6204,70 @@ export class DataTable {
5911
6204
  this._disposers.push(() => tbody.removeEventListener('click', handleRowClick));
5912
6205
  }
5913
6206
 
6207
+ // STORY-0010 `onRowKeyDown`. Bound BEFORE every keydown listener of the
6208
+ // table's own (Enter's activation, the detail keys, copy, `keyboardNav`),
6209
+ // so a key the consumer takes stops here.
6210
+ if (typeof this.config.onRowKeyDown === 'function') {
6211
+ const onRowKey = (ev) => {
6212
+ if (ev.isComposing || isRowControl(ev.target, table)) return;
6213
+ const tr = this._currentRowEl();
6214
+ let handled = false;
6215
+ try {
6216
+ handled = this.config.onRowKeyDown(tr ? tr.__rowIndex : null, tr ? tr.__row : null, ev) === true;
6217
+ } catch (err) {
6218
+ console.error('[DataTable] onRowKeyDown threw', err);
6219
+ }
6220
+ if (handled) {
6221
+ ev.preventDefault();
6222
+ ev.stopImmediatePropagation();
6223
+ }
6224
+ };
6225
+ table.addEventListener('keydown', onRowKey);
6226
+ this._disposers.push(() => table.removeEventListener('keydown', onRowKey));
6227
+ }
6228
+
6229
+ // STORY-0010 `keyboardNav`: the arrows move the selection through the
6230
+ // rows of the page, in the order shown.
6231
+ if (this.config.keyboardNav && selectable) {
6232
+ const NAV_KEYS = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
6233
+ const onNavKey = (ev) => {
6234
+ if (!NAV_KEYS.includes(ev.key) || ev.ctrlKey || ev.metaKey || ev.altKey) return;
6235
+ if (isRowControl(ev.target, table)) return;
6236
+ const order = this._pageOrder();
6237
+ if (!order.length) return;
6238
+ ev.preventDefault();
6239
+ const selected = this._state.selected;
6240
+ // Where the arrows left off, while that row is still selected;
6241
+ // else the anchor (a click since moved it).
6242
+ const cur = this._navCursor != null && selected.has(this._navCursor)
6243
+ ? this._navCursor : this._state.anchorIndex;
6244
+ let pos = order.indexOf(cur);
6245
+ if (ev.key === 'Home') pos = 0;
6246
+ else if (ev.key === 'End') pos = order.length - 1;
6247
+ else if (pos === -1) pos = ev.key === 'ArrowDown' ? 0 : order.length - 1;
6248
+ else pos = Math.max(0, Math.min(order.length - 1, pos + (ev.key === 'ArrowDown' ? 1 : -1)));
6249
+ const idx = order[pos];
6250
+ this._navCursor = idx;
6251
+ const anchorPos = order.indexOf(this._state.anchorIndex);
6252
+ if (ev.shiftKey && selectable !== 'single' && anchorPos !== -1) {
6253
+ // Shift: the range between the fixed anchor and the moving
6254
+ // row, as Shift-click selects it.
6255
+ const [a, b] = anchorPos <= pos ? [anchorPos, pos] : [pos, anchorPos];
6256
+ selected.clear();
6257
+ for (let i = a; i <= b; i++) selected.add(order[i]);
6258
+ } else {
6259
+ selected.clear();
6260
+ selected.add(idx);
6261
+ this._state.anchorIndex = idx;
6262
+ }
6263
+ this._updateRowSelection();
6264
+ this._notifySelectionChange();
6265
+ this.scrollToRow(idx);
6266
+ };
6267
+ table.addEventListener('keydown', onNavKey);
6268
+ this._disposers.push(() => table.removeEventListener('keydown', onNavKey));
6269
+ }
6270
+
5914
6271
  // 0.5.0 `onRowActivate`. Bound AFTER the selection handler on the same
5915
6272
  // element, so a row is selected before it is opened — the listener
5916
6273
  // order is the guarantee. `onRowClick` stays exactly where it was (on
@@ -5938,7 +6295,7 @@ export class DataTable {
5938
6295
  this._disposers.push(() => table.removeEventListener('keydown', onDetailKey));
5939
6296
  }
5940
6297
 
5941
- if (copyable) {
6298
+ if (copyable || customMenu) {
5942
6299
  const handleContextMenu = (event) => {
5943
6300
  const rowEl = event.target.closest('tr');
5944
6301
  if (!rowEl) {
@@ -5952,10 +6309,25 @@ export class DataTable {
5952
6309
  return;
5953
6310
  }
5954
6311
 
6312
+ // STORY-0010. The consumer's items, asked with the selection the
6313
+ // right-click is about to make, so `ctx` describes the rows the
6314
+ // person will act on. None, and no copy items: the page's menu.
6315
+ const idx = rowEl.__rowIndex;
6316
+ let custom = null;
6317
+ if (customMenu) {
6318
+ const selection = idx !== undefined && !this._state.selected.has(idx)
6319
+ ? [idx] : this.getSelection();
6320
+ const ctx = this._contextMenuCtxFor(rowEl, event.target, selection);
6321
+ custom = { ctx, items: this._customMenuItems(ctx) };
6322
+ }
6323
+ if (!copyable && !custom.items.length) {
6324
+ this._hideContextMenu();
6325
+ return;
6326
+ }
6327
+
5955
6328
  event.preventDefault();
5956
6329
  event.stopPropagation();
5957
6330
 
5958
- const idx = rowEl.__rowIndex;
5959
6331
  if (idx !== undefined && !this._state.selected.has(idx)) {
5960
6332
  this._state.selected.clear();
5961
6333
  this._state.selected.add(idx);
@@ -5965,11 +6337,14 @@ export class DataTable {
5965
6337
  }
5966
6338
 
5967
6339
  try { table.focus({ preventScroll: true }); } catch (_) {}
5968
- setTimeout(() => this._showContextMenu(event.clientX, event.clientY), 0);
6340
+ setTimeout(() => this._showContextMenu(event.clientX, event.clientY, custom), 0);
5969
6341
  };
5970
6342
 
5971
6343
  tbody.addEventListener('contextmenu', handleContextMenu);
5972
6344
  this._disposers.push(() => tbody.removeEventListener('contextmenu', handleContextMenu));
6345
+ }
6346
+
6347
+ if (copyable) {
5973
6348
 
5974
6349
  const handleKeyDown = (event) => {
5975
6350
  const ctrlLike = event.ctrlKey || event.metaKey;
@@ -6035,21 +6410,28 @@ export class DataTable {
6035
6410
  menu.style.display = 'none';
6036
6411
  menu.style.position = 'fixed';
6037
6412
  menu.style.zIndex = '10001';
6038
- menu.innerHTML = `
6039
- <div class="twm-context-menu-item" data-action="copy-tsv">
6040
- <span class="material-symbols-outlined">content_copy</span>
6041
- <span class="label">Copy Row(s)</span>
6042
- </div>
6043
- <div class="twm-context-menu-item" data-action="copy-csv">
6044
- <span class="material-symbols-outlined">table</span>
6045
- <span class="label">Copy as CSV</span>
6046
- </div>
6047
- `;
6413
+ menu.innerHTML = COPY_MENU_HTML;
6048
6414
  document.body.appendChild(menu);
6049
6415
 
6050
6416
  menu.addEventListener('click', (event) => {
6051
6417
  const item = event.target.closest('.twm-context-menu-item');
6052
6418
  if (!item || item.classList.contains('disabled')) return;
6419
+ // STORY-0010. One of the consumer's items: dispatched off the item
6420
+ // itself, not `data-action`, so an action is free to be called
6421
+ // 'copy-tsv'. The menu goes first — the action may open a dialog.
6422
+ if (item.__cmItem) {
6423
+ event.stopPropagation();
6424
+ event.preventDefault();
6425
+ const ctx = this._contextMenuCtx;
6426
+ this._hideContextMenu();
6427
+ // STORY-0012. The column chooser's own items.
6428
+ if (typeof item.__cmItem.onChoose === 'function') { item.__cmItem.onChoose(); return; }
6429
+ if (!ctx || typeof this.config.onContextMenuAction !== 'function') return;
6430
+ try { this.config.onContextMenuAction(item.__cmItem.action, ctx.rowIdx, ctx.row, ctx); } catch (err) {
6431
+ console.error('[DataTable] onContextMenuAction threw', err);
6432
+ }
6433
+ return;
6434
+ }
6053
6435
  const action = item.getAttribute('data-action');
6054
6436
  if (action === 'copy-tsv') this.copyToClipboard('tsv');
6055
6437
  else if (action === 'copy-csv') this.copyToClipboard('csv');
@@ -6112,12 +6494,15 @@ export class DataTable {
6112
6494
  this._disarmContextMenuGlobals();
6113
6495
  }
6114
6496
 
6115
- _showContextMenu(clientX, clientY) {
6497
+ _showContextMenu(clientX, clientY, custom = null) {
6116
6498
  const menu = this._ensureContextMenu();
6499
+ this._drawContextMenuItems(menu, custom);
6117
6500
  this._armContextMenuGlobals();
6118
6501
  const hasSelection = this._state.selected.size > 0;
6119
6502
 
6503
+ // The copy items need a selection; a consumer's item says for itself.
6120
6504
  menu.querySelectorAll('.twm-context-menu-item').forEach(item => {
6505
+ if (item.__cmItem) return;
6121
6506
  item.classList.toggle('disabled', !hasSelection);
6122
6507
  item.style.pointerEvents = hasSelection ? '' : 'none';
6123
6508
  });
@@ -6138,6 +6523,111 @@ export class DataTable {
6138
6523
  menu.style.top = `${top}px`;
6139
6524
  }
6140
6525
 
6526
+ /**
6527
+ * STORY-0010. The `ctx` handed to `contextMenuItems` and, later, to
6528
+ * `onContextMenuAction`: the right-clicked row and cell, and `selection`
6529
+ * (original indices, ascending) with its rows 1:1.
6530
+ */
6531
+ _contextMenuCtxFor(rowEl, target, selection) {
6532
+ const rows = this.config.rows || [];
6533
+ const rowIdx = rowEl.__rowIndex === undefined ? null : rowEl.__rowIndex;
6534
+ const row = rowIdx === null ? null : (rows[rowIdx] ?? null);
6535
+ // The DATA column under the pointer. Only a cell of THIS row counts (a
6536
+ // `renderCell` may plant a table of its own), and the row-number gutter
6537
+ // is no data column.
6538
+ let colIdx = null;
6539
+ const cellEl = target?.closest?.('td');
6540
+ if (cellEl && cellEl.parentNode === rowEl) {
6541
+ const domIdx = Array.prototype.indexOf.call(rowEl.children, cellEl);
6542
+ const dataIdx = this._dataIdxOfDom(domIdx);
6543
+ if (dataIdx !== null) colIdx = dataIdx;
6544
+ }
6545
+ const sel = [...selection].sort((a, b) => a - b);
6546
+ return {
6547
+ rowIdx,
6548
+ row,
6549
+ colIdx,
6550
+ value: colIdx !== null && row ? row[colIdx] : null,
6551
+ selection: sel,
6552
+ selectedRows: sel.map((i) => rows[i]),
6553
+ };
6554
+ }
6555
+
6556
+ /** STORY-0010. `contextMenuItems` for one right-click: none off a data row
6557
+ * (the empty row), and none from a function that threw. */
6558
+ _customMenuItems(ctx) {
6559
+ if (ctx.rowIdx === null) return [];
6560
+ const spec = this.config.contextMenuItems;
6561
+ let items = spec;
6562
+ if (typeof spec === 'function') {
6563
+ try { items = spec(ctx.row, ctx.rowIdx, ctx); } catch (err) {
6564
+ console.error('[DataTable] contextMenuItems threw', err);
6565
+ items = null;
6566
+ }
6567
+ }
6568
+ return Array.isArray(items) ? items.filter(Boolean) : [];
6569
+ }
6570
+
6571
+ /**
6572
+ * STORY-0010. Fill the menu for this right-click: the consumer's items,
6573
+ * then (with `copyable`) a separator and the copy items. Without custom
6574
+ * items the menu is the 0.9.1 copy menu, and is only rebuilt when the last
6575
+ * right-click drew custom ones into it. Built with DOM calls, not markup:
6576
+ * the labels are the consumer's text.
6577
+ */
6578
+ _drawContextMenuItems(menu, custom) {
6579
+ this._contextMenuCtx = custom ? custom.ctx : null;
6580
+ if (!custom) {
6581
+ if (this._contextMenuCustom) {
6582
+ menu.innerHTML = COPY_MENU_HTML;
6583
+ this._contextMenuCustom = false;
6584
+ }
6585
+ return;
6586
+ }
6587
+ this._contextMenuCustom = true;
6588
+ menu.textContent = '';
6589
+ for (const item of custom.items) {
6590
+ if (item.separator) {
6591
+ const sep = document.createElement('div');
6592
+ sep.className = 'twm-context-menu__separator';
6593
+ menu.appendChild(sep);
6594
+ continue;
6595
+ }
6596
+ const el = document.createElement('div');
6597
+ el.className = 'twm-context-menu-item';
6598
+ if (item.action != null) el.setAttribute('data-action', String(item.action));
6599
+ if (item.danger) el.classList.add('twm-delete-node');
6600
+ if (item.disabled) {
6601
+ el.classList.add('disabled');
6602
+ el.style.pointerEvents = 'none';
6603
+ el.setAttribute('aria-disabled', 'true');
6604
+ }
6605
+ if (item.title) el.title = String(item.title);
6606
+ // STORY-0012. The column chooser's items say whether they are on.
6607
+ if (typeof item.checked === 'boolean') {
6608
+ el.setAttribute('role', 'menuitemcheckbox');
6609
+ el.setAttribute('aria-checked', String(item.checked));
6610
+ }
6611
+ const icon = document.createElement('span');
6612
+ icon.className = 'material-symbols-outlined';
6613
+ icon.textContent = item.icon == null ? '' : String(item.icon);
6614
+ const label = document.createElement('span');
6615
+ label.className = 'label';
6616
+ label.textContent = item.label == null ? '' : String(item.label);
6617
+ el.append(icon, label);
6618
+ el.__cmItem = item;
6619
+ menu.appendChild(el);
6620
+ }
6621
+ if (this.config.copyable && custom.copy !== false) {
6622
+ if (custom.items.length) {
6623
+ const sep = document.createElement('div');
6624
+ sep.className = 'twm-context-menu__separator';
6625
+ menu.appendChild(sep);
6626
+ }
6627
+ menu.insertAdjacentHTML('beforeend', COPY_MENU_HTML);
6628
+ }
6629
+ }
6630
+
6141
6631
  _teardownContextMenu() {
6142
6632
  this._contextMenuGlobals.forEach(dispose => {
6143
6633
  try { dispose?.(); } catch (_) {}