flexdesk 0.10.0 → 0.11.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.
- package/README.md +190 -0
- package/css/flexdesk.css +46 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-H4LT43RS.js → chunk-5QPGYHBN.js} +2 -2
- package/dist/{chunk-JLCJWCRS.js → chunk-6CGH73BC.js} +451 -32
- package/dist/chunk-6CGH73BC.js.map +7 -0
- package/dist/{chunk-6NG7YVBG.js → chunk-6EZPPN4E.js} +134 -13
- package/dist/chunk-6EZPPN4E.js.map +7 -0
- package/dist/{chunk-WKTYKXWF.js → chunk-EHHTKD7B.js} +2 -2
- package/dist/flexdesk.css +46 -0
- package/dist/flow.js +2 -2
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +50 -5
- package/dist/widgets.js.map +2 -2
- package/dist/wm.js +2 -2
- package/package.json +1 -1
- package/src/ui/components/data_table.js +521 -31
- package/src/ui/components/modal.js +191 -12
- package/src/ui/notification_center.js +48 -1
- package/dist/chunk-6NG7YVBG.js.map +0 -7
- package/dist/chunk-JLCJWCRS.js.map +0 -7
- /package/dist/{chunk-H4LT43RS.js.map → chunk-5QPGYHBN.js.map} +0 -0
- /package/dist/{chunk-WKTYKXWF.js.map → chunk-EHHTKD7B.js.map} +0 -0
|
@@ -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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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 =
|
|
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,
|
|
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 =
|
|
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.
|
|
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 (_) {}
|