flexdesk 0.11.1 → 0.12.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.
- package/README.md +55 -0
- package/css/flexdesk.css +105 -9
- package/css/overrides.css +29 -9
- package/dist/charts.js +2 -2
- package/dist/{chunk-EHHTKD7B.js → chunk-2LEJQD4H.js} +2 -2
- package/dist/{chunk-5QPGYHBN.js → chunk-CWPMLX67.js} +2 -2
- package/dist/{chunk-6CGH73BC.js → chunk-M2L4XW2U.js} +224 -31
- package/dist/chunk-M2L4XW2U.js.map +7 -0
- package/dist/{chunk-6EZPPN4E.js → chunk-NNFTETK3.js} +8 -4
- package/dist/chunk-NNFTETK3.js.map +7 -0
- package/dist/flexdesk.css +105 -9
- package/dist/flow.js +2 -2
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +3 -3
- package/dist/wm.js +23 -5
- package/dist/wm.js.map +2 -2
- package/package.json +1 -1
- package/src/tiling/content_registry.js +5 -0
- package/src/tiling/tile_renderer.js +45 -3
- package/src/ui/components/data_table.js +266 -31
- package/src/ui/components/modal.js +4 -2
- package/dist/chunk-6CGH73BC.js.map +0 -7
- package/dist/chunk-6EZPPN4E.js.map +0 -7
- /package/dist/{chunk-EHHTKD7B.js.map → chunk-2LEJQD4H.js.map} +0 -0
- /package/dist/{chunk-5QPGYHBN.js.map → chunk-CWPMLX67.js.map} +0 -0
package/package.json
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* The tile renderer calls the factory once when the leaf is first
|
|
10
10
|
* mounted with a given (kind, props) pair, and `destroy()` on unmount.
|
|
11
11
|
*
|
|
12
|
+
* `ctx` is the shell's ctx plus, per leaf, `leafId` and `setTitle(text)` —
|
|
13
|
+
* rename the pane's chrome title from inside the content, at mount or any
|
|
14
|
+
* time after; it survives repaints, and `setTitle(null)` gives the title back
|
|
15
|
+
* to the tree (tile_renderer.js, C41).
|
|
16
|
+
*
|
|
12
17
|
* ── ONE REGISTRY PER SHELL ────────────────────────────────────────────
|
|
13
18
|
* This module used to hold a module-level `Map` plus a `register(kind, fn)`
|
|
14
19
|
* export. Two problems, both structural:
|
|
@@ -75,6 +75,22 @@
|
|
|
75
75
|
* no content factory is unmounted and nothing mounted in a tile — a grid with
|
|
76
76
|
* staged edits, an editor with an undo stack — notices that it happened.
|
|
77
77
|
*
|
|
78
|
+
* ── C41. A CONTENT CAN RENAME ITS OWN PANE ───────────────────────────────
|
|
79
|
+
*
|
|
80
|
+
* The per-leaf ctx carries `setTitle(text)`. A factory's returned `title` is
|
|
81
|
+
* read once, after the mount, and the next repaint puts `leaf.title` back — and
|
|
82
|
+
* for a `panel:*` leaf that is PANEL_TITLES, which `wm._canonicalize` rewrites
|
|
83
|
+
* on every pass. So a bottom panel with tabs of its own could never say which
|
|
84
|
+
* one it was showing: it read "Console" whatever it held.
|
|
85
|
+
*
|
|
86
|
+
* `ctx.setTitle(text)` paints the chrome's title now and keeps it on the cache
|
|
87
|
+
* entry, where every cached repaint prefers it to `leaf.title`. Called DURING
|
|
88
|
+
* the mount it wins over the returned `title`. `setTitle(null)` or `''` hands
|
|
89
|
+
* the title back to the tree. It changes what the chrome SHOWS only: the tree,
|
|
90
|
+
* the tab labels and what is persisted are untouched, so a reload shows the
|
|
91
|
+
* tree's title until the content mounts and says otherwise. A call from a
|
|
92
|
+
* content whose leaf has since been rebuilt does nothing.
|
|
93
|
+
*
|
|
78
94
|
* ── C33. A TAB DRAGGED INTO A TILE ───────────────────────────────────────
|
|
79
95
|
*
|
|
80
96
|
* *"I would like to be able to drag & drop tabs into tiles, too."* A tab could
|
|
@@ -526,7 +542,10 @@ export class TileRenderer {
|
|
|
526
542
|
|
|
527
543
|
let entry = this._leafCache.get(leaf.id);
|
|
528
544
|
if (entry && entry.kindKey === kindKey) {
|
|
529
|
-
|
|
545
|
+
// C41. A title the content set through `ctx.setTitle` wins over
|
|
546
|
+
// the tree's: a panel leaf's `title` is rewritten to PANEL_TITLES
|
|
547
|
+
// by every canonicalize, and this repaint follows it.
|
|
548
|
+
entry.titleEl.textContent = entry.liveTitle || leaf.title || (leaf.content ? leaf.content.kind : 'empty');
|
|
530
549
|
// C19. Re-read the glyph beside the title, and for the same reason:
|
|
531
550
|
// a cached leaf is NOT re-mounted, so anything the chrome shows
|
|
532
551
|
// that can change while the content stays put has to be refreshed
|
|
@@ -797,8 +816,27 @@ export class TileRenderer {
|
|
|
797
816
|
// `wm.openFromContext(ctx, ...)` instead of always hitting
|
|
798
817
|
// the primary tile.
|
|
799
818
|
let content = {};
|
|
819
|
+
// C41. The title the content set through `ctx.setTitle`, held here
|
|
820
|
+
// until the cache entry exists (a call made DURING the mount) and on
|
|
821
|
+
// the entry after that.
|
|
822
|
+
let liveTitle = null;
|
|
823
|
+
let liveEntry = null;
|
|
800
824
|
if (leaf.content) {
|
|
801
|
-
const
|
|
825
|
+
const setTitle = (text) => {
|
|
826
|
+
const next = text == null || text === '' ? null : String(text);
|
|
827
|
+
if (!liveEntry) {
|
|
828
|
+
liveTitle = next;
|
|
829
|
+
title.textContent = next || leaf.title || leaf.content.kind;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
// A leaf rebuilt since — its old content still holding this
|
|
833
|
+
// ctx — must not write into the new one's chrome.
|
|
834
|
+
if (this._leafCache.get(leaf.id) !== liveEntry) return;
|
|
835
|
+
liveEntry.liveTitle = next;
|
|
836
|
+
const now = this.tree?.get?.(leaf.id) || leaf;
|
|
837
|
+
liveEntry.titleEl.textContent = next || now.title || (now.content ? now.content.kind : 'empty');
|
|
838
|
+
};
|
|
839
|
+
const leafCtx = { ...this.ctx, leafId: leaf.id, setTitle };
|
|
802
840
|
// Mount with the ACTIVE TAB's props, not `content.props`.
|
|
803
841
|
// `wm.updateActiveTabProps` (the canonical per-tile view-state
|
|
804
842
|
// store — survives reload + desktop switches via desktops.json)
|
|
@@ -810,6 +848,7 @@ export class TileRenderer {
|
|
|
810
848
|
const activeProps = activeTab?.props ?? leaf.content.props;
|
|
811
849
|
content = this.content.mount(leaf.content.kind, body, activeProps, leafCtx);
|
|
812
850
|
if (content.title) title.textContent = content.title;
|
|
851
|
+
if (liveTitle) title.textContent = liveTitle;
|
|
813
852
|
// C19. `icon` is the natural sibling of `title` and is read the
|
|
814
853
|
// same way: AFTER the mount, because that is the first moment the
|
|
815
854
|
// content knows what it is — a canvas pane you emptied is the same
|
|
@@ -848,8 +887,11 @@ export class TileRenderer {
|
|
|
848
887
|
// C22. The `top` layout's NotebookTabBar, and the child it
|
|
849
888
|
// mounts into. Null under the `bottom` layout, which is plain
|
|
850
889
|
// markup this file writes itself.
|
|
851
|
-
tabStrip: null, tabStripHostEl: null
|
|
890
|
+
tabStrip: null, tabStripHostEl: null,
|
|
891
|
+
// C41. Null until the content calls `ctx.setTitle`.
|
|
892
|
+
liveTitle };
|
|
852
893
|
this._leafCache.set(leaf.id, entry);
|
|
894
|
+
liveEntry = entry;
|
|
853
895
|
|
|
854
896
|
// Render tab strip last so it has access to the cached entry.
|
|
855
897
|
this._renderTabBar(leaf, entry);
|
|
@@ -397,6 +397,9 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
397
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
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
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 {boolean} [selectToggle=false] - STORY-0016. The table offers a SELECT MODE: a narrow leading column whose header holds a toggle button. While the mode is on, every row shows a checkbox there and the header a select-all box; a plain click (or Space) ticks or unticks one row, Shift-click adds the range from the last row ticked, and no gesture opens a row (`onRowActivate` is not called; `onRowClick` still is — a consumer that opens rows from it asks `isSelectMode()`). The arrows move a cursor without changing what is ticked. A right-click keeps the ticks. Escape clears the ticks, then leaves the mode. Needs a multi-row `selectable` (ignored, with a warning, on `false` or 'single'). The column is not resizable, is no data column (not in a copy, a CSV, `ctx.colIdx` or `hiddenColumns`), and remembered widths stay on their columns.
|
|
401
|
+
* @property {boolean} [selectMode=false] - STORY-0016. Start in select mode. Draws the select column even without `selectToggle` (then there is no toggle: the consumer turns the mode off with `setSelectMode(false)`).
|
|
402
|
+
* @property {Function} [onSelectModeChange] - STORY-0016. `(on) => void`, after every change of the mode (the toggle, Escape, `setSelectMode`).
|
|
400
403
|
* @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
404
|
* @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.
|
|
402
405
|
*/
|
|
@@ -543,9 +546,26 @@ export class DataTable {
|
|
|
543
546
|
hiddenColumns: null,
|
|
544
547
|
columnChooser: false,
|
|
545
548
|
onColumnsChange: null,
|
|
549
|
+
// STORY-0016. Off — no select column, as in 0.11 — until set.
|
|
550
|
+
selectToggle: false,
|
|
551
|
+
selectMode: false,
|
|
552
|
+
onSelectModeChange: null,
|
|
546
553
|
...config,
|
|
547
554
|
};
|
|
548
555
|
|
|
556
|
+
// STORY-0016. The select column is drawn for the table's whole life or
|
|
557
|
+
// not at all, so the drawn column indices (and the widths kept by them)
|
|
558
|
+
// never move when the mode flips.
|
|
559
|
+
{
|
|
560
|
+
const sel = this.config.selectable;
|
|
561
|
+
const asked = !!this.config.selectToggle || !!this.config.selectMode;
|
|
562
|
+
this._gutter = asked && !!sel && sel !== 'single';
|
|
563
|
+
if (asked && !this._gutter) {
|
|
564
|
+
console.warn('[DataTable] selectToggle/selectMode need a multi-row `selectable`; ignored.');
|
|
565
|
+
}
|
|
566
|
+
this._selecting = this._gutter && !!this.config.selectMode;
|
|
567
|
+
}
|
|
568
|
+
|
|
549
569
|
// 0.7.0. Open details (by key), the server's half, follow, and the
|
|
550
570
|
// filter controls that are not text boxes.
|
|
551
571
|
this._expanded = new Set((this.config.expandedRows || []).map((k) => String(k)));
|
|
@@ -704,8 +724,12 @@ export class DataTable {
|
|
|
704
724
|
if (blob.colWidths && typeof blob.colWidths === 'object') {
|
|
705
725
|
// Stored under string keys (JSON) → coerce back to ints.
|
|
706
726
|
this._colWidths = {};
|
|
727
|
+
// STORY-0016. Widths are by drawn column; a blob saved before this
|
|
728
|
+
// table drew its select column (or a consumer's seed) has none.
|
|
729
|
+
const shift = this._lead().select - (Number(blob.colWidthsLead) || 0);
|
|
707
730
|
for (const [k, v] of Object.entries(blob.colWidths)) {
|
|
708
|
-
|
|
731
|
+
const i = Number(k) + shift;
|
|
732
|
+
if (i >= this._lead().select) this._colWidths[i] = v;
|
|
709
733
|
}
|
|
710
734
|
// Stamp the matching signature so the next render keeps these
|
|
711
735
|
// widths instead of treating them as stale.
|
|
@@ -742,6 +766,9 @@ export class DataTable {
|
|
|
742
766
|
sortAscending: this._state.sortAscending,
|
|
743
767
|
filters: [...this._state.filters.entries()],
|
|
744
768
|
colWidths: { ...this._colWidths },
|
|
769
|
+
// STORY-0016. Where the widths start, so a blob read by a table
|
|
770
|
+
// with or without the select column lands on the same columns.
|
|
771
|
+
...(this._gutter ? { colWidthsLead: 1 } : {}),
|
|
745
772
|
// STORY-0010. Only for a table that asked: one that did not writes
|
|
746
773
|
// the 0.9.1 blob, key for key.
|
|
747
774
|
...(this.config.persistPage ? { offset: this._state.offset } : {}),
|
|
@@ -778,20 +805,27 @@ export class DataTable {
|
|
|
778
805
|
/** The cells a drawn row has: the shown columns, and the row number. */
|
|
779
806
|
_domColCount() {
|
|
780
807
|
const n = Math.max(0, (this.config.headers?.length || 0) - this._hidden.size);
|
|
781
|
-
return n +
|
|
808
|
+
return n + this._lead().all;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** STORY-0016. The drawn columns in front of the data: the select column
|
|
812
|
+
* (`select`, 0 or 1, always first) and the row number. */
|
|
813
|
+
_lead() {
|
|
814
|
+
const select = this._gutter ? 1 : 0;
|
|
815
|
+
return { select, all: select + (this.config.showRowNumbers ? 1 : 0) };
|
|
782
816
|
}
|
|
783
817
|
|
|
784
818
|
/** A data column's drawn index (row number included), or -1 when hidden. */
|
|
785
819
|
_domIdxOf(colIdx) {
|
|
786
820
|
if (this._isHidden(colIdx)) return -1;
|
|
787
|
-
let dom = this.
|
|
821
|
+
let dom = this._lead().all;
|
|
788
822
|
for (const h of this._hidden) if (h < colIdx) dom--;
|
|
789
823
|
return dom + colIdx;
|
|
790
824
|
}
|
|
791
825
|
|
|
792
826
|
/** A drawn cell's data column, or null for the row number. */
|
|
793
827
|
_dataIdxOfDom(domIdx) {
|
|
794
|
-
let i = domIdx -
|
|
828
|
+
let i = domIdx - this._lead().all;
|
|
795
829
|
if (i < 0) return null;
|
|
796
830
|
const n = this.config.headers?.length || 0;
|
|
797
831
|
for (let c = 0; c < n; c++) {
|
|
@@ -840,7 +874,7 @@ export class DataTable {
|
|
|
840
874
|
this._setHidden([...next]);
|
|
841
875
|
if (kept) {
|
|
842
876
|
const widths = {};
|
|
843
|
-
|
|
877
|
+
for (let i = 0; i < this._lead().all; i++) if (old[i] != null) widths[i] = old[i];
|
|
844
878
|
for (let c = 0; c < n; c++) {
|
|
845
879
|
const from = domBefore[c];
|
|
846
880
|
const to = this._domIdxOf(c);
|
|
@@ -887,6 +921,14 @@ export class DataTable {
|
|
|
887
921
|
* @param {Object} updates - Partial config updates (headers, rows, etc.)
|
|
888
922
|
*/
|
|
889
923
|
setData(updates) {
|
|
924
|
+
// STORY-0016. In select mode, ticks are kept by `getRowKey` across new
|
|
925
|
+
// rows (read before they are replaced): a list refreshed under somebody halfway through ticking twenty
|
|
926
|
+
// rows must not throw them away. Only in the mode; elsewhere new rows
|
|
927
|
+
// are an answer, as before.
|
|
928
|
+
const keepTicks = updates.rows && this._selecting && typeof this.config.getRowKey === 'function'
|
|
929
|
+
&& this._state.selected.size
|
|
930
|
+
? new Set(this.getSelection().map((i) => this._rowKey(this.config.rows[i], i)).filter((k) => k !== null))
|
|
931
|
+
: null;
|
|
890
932
|
// Clear filters if headers changed
|
|
891
933
|
if (updates.headers && this._state.filters.size > 0) {
|
|
892
934
|
const oldHeaders = this.config.headers;
|
|
@@ -951,11 +993,19 @@ export class DataTable {
|
|
|
951
993
|
this._columnTypes = this._detectColumnTypes();
|
|
952
994
|
}
|
|
953
995
|
|
|
996
|
+
// STORY-0016. The ticks, found again in the new rows by key.
|
|
997
|
+
if (keepTicks) {
|
|
998
|
+
(this.config.rows || []).forEach((r, i) => {
|
|
999
|
+
if (keepTicks.has(this._rowKey(r, i))) this._state.selected.add(i);
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
|
|
954
1003
|
// Invalidate processed cache
|
|
955
1004
|
this._processedRows = null;
|
|
956
1005
|
this._processedIndexMap = null;
|
|
957
1006
|
|
|
958
1007
|
this.render();
|
|
1008
|
+
if (keepTicks) this._notifySelectionChange();
|
|
959
1009
|
}
|
|
960
1010
|
|
|
961
1011
|
/**
|
|
@@ -1397,6 +1447,7 @@ export class DataTable {
|
|
|
1397
1447
|
headerTable.className = this._tableClassName();
|
|
1398
1448
|
const thead = document.createElement('thead');
|
|
1399
1449
|
const tr = document.createElement('tr');
|
|
1450
|
+
if (this._gutter) tr.appendChild(this._buildSelectHead());
|
|
1400
1451
|
if (showRowNumbers) {
|
|
1401
1452
|
const th = document.createElement('th');
|
|
1402
1453
|
th.className = 'num';
|
|
@@ -2009,6 +2060,11 @@ export class DataTable {
|
|
|
2009
2060
|
const tr = document.createElement('tr');
|
|
2010
2061
|
tr.className = 'twm-data-table__filter-row';
|
|
2011
2062
|
|
|
2063
|
+
if (this._gutter) {
|
|
2064
|
+
const th = document.createElement('th');
|
|
2065
|
+
th.className = 'data-table__filter-cell data-table__filter-cell--empty twm-dt-select-cell';
|
|
2066
|
+
tr.appendChild(th);
|
|
2067
|
+
}
|
|
2012
2068
|
if (showRowNumbers) {
|
|
2013
2069
|
const th = document.createElement('th');
|
|
2014
2070
|
th.className = 'data-table__filter-cell data-table__filter-cell--empty';
|
|
@@ -2451,7 +2507,7 @@ export class DataTable {
|
|
|
2451
2507
|
if (!row) return null;
|
|
2452
2508
|
const cells = Array.from(row.children);
|
|
2453
2509
|
const idx = cells.indexOf(cell);
|
|
2454
|
-
return this.
|
|
2510
|
+
return this._dataIdxOfDom(idx);
|
|
2455
2511
|
}
|
|
2456
2512
|
|
|
2457
2513
|
_restoreFilterFocus(colIdx) {
|
|
@@ -3013,6 +3069,7 @@ export class DataTable {
|
|
|
3013
3069
|
const thead = document.createElement('thead');
|
|
3014
3070
|
const headerRow = document.createElement('tr');
|
|
3015
3071
|
|
|
3072
|
+
if (this._gutter) headerRow.appendChild(this._buildSelectHead());
|
|
3016
3073
|
if (showRowNumbers) {
|
|
3017
3074
|
const th = document.createElement('th');
|
|
3018
3075
|
th.className = 'num';
|
|
@@ -3225,7 +3282,7 @@ export class DataTable {
|
|
|
3225
3282
|
const colgroup = document.createElement('colgroup');
|
|
3226
3283
|
for (let i = 0; i < n; i++) {
|
|
3227
3284
|
const col = document.createElement('col');
|
|
3228
|
-
const w = this._colWidths?.[i];
|
|
3285
|
+
const w = i < this._lead().select ? this._selectColWidth() : this._colWidths?.[i];
|
|
3229
3286
|
if (w != null && this._colWidthsSig === this._colSig()) col.style.width = `${w}px`;
|
|
3230
3287
|
colgroup.appendChild(col);
|
|
3231
3288
|
}
|
|
@@ -3645,6 +3702,24 @@ export class DataTable {
|
|
|
3645
3702
|
_fillRowCells(tr, row, globalIdx, showRowNumbers) {
|
|
3646
3703
|
tr.replaceChildren();
|
|
3647
3704
|
|
|
3705
|
+
// STORY-0016. The select column: a checkbox, shown in select mode. It
|
|
3706
|
+
// takes no focus and no click of its own — the row's click ticks it.
|
|
3707
|
+
if (this._gutter) {
|
|
3708
|
+
const td = document.createElement('td');
|
|
3709
|
+
td.className = 'twm-dt-select-cell';
|
|
3710
|
+
const box = document.createElement('input');
|
|
3711
|
+
box.type = 'checkbox';
|
|
3712
|
+
box.className = 'twm-dt-select-box';
|
|
3713
|
+
box.tabIndex = -1;
|
|
3714
|
+
box.checked = this._state.selected.has(globalIdx);
|
|
3715
|
+
box.setAttribute('aria-label', 'Select row');
|
|
3716
|
+
// Its click is the row's: the box flips, the row's handler ticks
|
|
3717
|
+
// and sets it to the tick. (Prevented, the browser would flip it
|
|
3718
|
+
// back AFTER the handler.)
|
|
3719
|
+
td.appendChild(box);
|
|
3720
|
+
tr.appendChild(td);
|
|
3721
|
+
}
|
|
3722
|
+
|
|
3648
3723
|
if (showRowNumbers) {
|
|
3649
3724
|
const td = document.createElement('td');
|
|
3650
3725
|
td.className = 'num';
|
|
@@ -3800,7 +3875,7 @@ export class DataTable {
|
|
|
3800
3875
|
: null;
|
|
3801
3876
|
|
|
3802
3877
|
const showRowNumbers = this.config.showRowNumbers !== false
|
|
3803
|
-
&& tr.
|
|
3878
|
+
&& !!tr.querySelector(':scope > td.num');
|
|
3804
3879
|
this._fillRowCells(tr, row, index, showRowNumbers);
|
|
3805
3880
|
|
|
3806
3881
|
// `_syncHeaderWidths` derives column widths from the FIRST body row.
|
|
@@ -5005,6 +5080,9 @@ export class DataTable {
|
|
|
5005
5080
|
: ['twm-preview-table'];
|
|
5006
5081
|
if (this.config.firstColumn === 'plain') names.push('twm-dt--first-plain');
|
|
5007
5082
|
if (this._isClickable()) names.push('twm-dt--clickable');
|
|
5083
|
+
// STORY-0016. The select column, and whether the mode is on.
|
|
5084
|
+
if (this._gutter) names.push('twm-dt--select');
|
|
5085
|
+
if (this._selecting) names.push('twm-dt--selecting');
|
|
5008
5086
|
return names.join(' ');
|
|
5009
5087
|
}
|
|
5010
5088
|
|
|
@@ -5101,6 +5179,8 @@ export class DataTable {
|
|
|
5101
5179
|
// A table disposed by the press (its selection callback threw the
|
|
5102
5180
|
// list away) opens nothing.
|
|
5103
5181
|
if (!this._wrapperEl || typeof this.config.onRowActivate !== 'function') return;
|
|
5182
|
+
// STORY-0016. In select mode a gesture ticks; nothing opens.
|
|
5183
|
+
if (this._selecting) return;
|
|
5104
5184
|
try { this.config.onRowActivate(tr.__rowIndex, tr.__row, ev); } catch (err) {
|
|
5105
5185
|
console.error('[DataTable] onRowActivate threw', err);
|
|
5106
5186
|
}
|
|
@@ -5527,7 +5607,7 @@ export class DataTable {
|
|
|
5527
5607
|
*/
|
|
5528
5608
|
autoSizeColumn(domIdx, opts = {}) {
|
|
5529
5609
|
const natural = this._measureNaturalWidths({ sample: true });
|
|
5530
|
-
if (!natural || domIdx <
|
|
5610
|
+
if (!natural || domIdx < this._lead().select || domIdx >= natural.length) return false;
|
|
5531
5611
|
this._colWidths[domIdx] = this._autoWidth(natural[domIdx], opts);
|
|
5532
5612
|
this._colWidthsSig = this._colSig();
|
|
5533
5613
|
this._savePersisted();
|
|
@@ -5554,7 +5634,7 @@ export class DataTable {
|
|
|
5554
5634
|
const natural = this._measureNaturalWidths({ sample: true });
|
|
5555
5635
|
if (!natural) return false;
|
|
5556
5636
|
this._colWidths = {};
|
|
5557
|
-
for (let i =
|
|
5637
|
+
for (let i = this._lead().select; i < natural.length; i++) {
|
|
5558
5638
|
this._colWidths[i] = this._autoWidth(natural[i], opts);
|
|
5559
5639
|
}
|
|
5560
5640
|
this._colWidthsSig = this._colSig();
|
|
@@ -5588,7 +5668,7 @@ export class DataTable {
|
|
|
5588
5668
|
*/
|
|
5589
5669
|
_allColumnsPinned(n) {
|
|
5590
5670
|
if (!n || this._colWidthsSig !== this._colSig()) return false;
|
|
5591
|
-
for (let i =
|
|
5671
|
+
for (let i = this._lead().select; i < n; i++) {
|
|
5592
5672
|
if (this._colWidths[i] == null) return false;
|
|
5593
5673
|
}
|
|
5594
5674
|
return true;
|
|
@@ -5640,10 +5720,12 @@ export class DataTable {
|
|
|
5640
5720
|
const wrap = this._tableWrapEl;
|
|
5641
5721
|
const headerWrap = this._headerWrapEl;
|
|
5642
5722
|
const avail = wrap ? wrap.clientWidth : 0;
|
|
5643
|
-
const
|
|
5723
|
+
const lead = this._lead().all;
|
|
5724
|
+
const firstIdx = lead && n > lead ? lead : 0;
|
|
5644
5725
|
const widths = this._fitColumnWidths(natural, avail, {
|
|
5645
5726
|
firstIdx,
|
|
5646
|
-
|
|
5727
|
+
// STORY-0016. The select column is as wide as its controls, always.
|
|
5728
|
+
overrides: this._gutter ? { ...this._colWidths, 0: this._selectColWidth() } : this._colWidths,
|
|
5647
5729
|
fit: this.config.columnFit,
|
|
5648
5730
|
floors: this._filterFloors,
|
|
5649
5731
|
});
|
|
@@ -5968,6 +6050,7 @@ export class DataTable {
|
|
|
5968
6050
|
if (!headRow) return;
|
|
5969
6051
|
[...headRow.children].forEach((th, domIdx) => {
|
|
5970
6052
|
if (th.querySelector('.twm-dt-col-resizer')) return;
|
|
6053
|
+
if (th.classList.contains('twm-dt-select-cell')) return; // STORY-0016: fixed
|
|
5971
6054
|
th.classList.add('twm-dt-col');
|
|
5972
6055
|
const grip = document.createElement('div');
|
|
5973
6056
|
grip.className = 'twm-dt-col-resizer';
|
|
@@ -6180,6 +6263,21 @@ export class DataTable {
|
|
|
6180
6263
|
if (!(was && (event.metaKey || event.ctrlKey))) selected.add(idx);
|
|
6181
6264
|
else DESELECTING_CLICKS.add(event); // so it does not ALSO open it
|
|
6182
6265
|
this._state.anchorIndex = selected.size ? idx : null;
|
|
6266
|
+
} else if (this._selecting) {
|
|
6267
|
+
// STORY-0016. A plain click ticks one row; Shift adds the
|
|
6268
|
+
// range from the last row ticked, as SHOWN.
|
|
6269
|
+
if (event.shiftKey && this._state.anchorIndex != null) {
|
|
6270
|
+
const order = this._processedIndexMap || this.config.rows.map((_, i) => i);
|
|
6271
|
+
const a = order.indexOf(this._state.anchorIndex);
|
|
6272
|
+
const b = order.indexOf(idx);
|
|
6273
|
+
if (a >= 0 && b >= 0) {
|
|
6274
|
+
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) selected.add(order[i]);
|
|
6275
|
+
} else selected.add(idx);
|
|
6276
|
+
} else if (selected.has(idx)) selected.delete(idx);
|
|
6277
|
+
else selected.add(idx);
|
|
6278
|
+
this._state.anchorIndex = idx;
|
|
6279
|
+
this._navCursor = idx;
|
|
6280
|
+
this._markCursor();
|
|
6183
6281
|
} else if (event.shiftKey && this._state.anchorIndex != null) {
|
|
6184
6282
|
const start = Math.min(this._state.anchorIndex, idx);
|
|
6185
6283
|
const end = Math.max(this._state.anchorIndex, idx);
|
|
@@ -6239,7 +6337,7 @@ export class DataTable {
|
|
|
6239
6337
|
const selected = this._state.selected;
|
|
6240
6338
|
// Where the arrows left off, while that row is still selected;
|
|
6241
6339
|
// else the anchor (a click since moved it).
|
|
6242
|
-
const cur = this._navCursor != null && selected.has(this._navCursor)
|
|
6340
|
+
const cur = this._navCursor != null && (this._selecting || selected.has(this._navCursor))
|
|
6243
6341
|
? this._navCursor : this._state.anchorIndex;
|
|
6244
6342
|
let pos = order.indexOf(cur);
|
|
6245
6343
|
if (ev.key === 'Home') pos = 0;
|
|
@@ -6249,6 +6347,22 @@ export class DataTable {
|
|
|
6249
6347
|
const idx = order[pos];
|
|
6250
6348
|
this._navCursor = idx;
|
|
6251
6349
|
const anchorPos = order.indexOf(this._state.anchorIndex);
|
|
6350
|
+
if (this._selecting) {
|
|
6351
|
+
// STORY-0016. The arrows move the cursor; the ticks stay.
|
|
6352
|
+
// Shift ticks the rows passed over.
|
|
6353
|
+
if (ev.shiftKey) {
|
|
6354
|
+
const from = order.indexOf(cur);
|
|
6355
|
+
if (from >= 0) {
|
|
6356
|
+
for (let i = Math.min(from, pos); i <= Math.max(from, pos); i++) selected.add(order[i]);
|
|
6357
|
+
} else selected.add(idx);
|
|
6358
|
+
this._updateRowSelection();
|
|
6359
|
+
this._notifySelectionChange();
|
|
6360
|
+
}
|
|
6361
|
+
this._state.anchorIndex = idx;
|
|
6362
|
+
this._markCursor();
|
|
6363
|
+
this.scrollToRow(idx);
|
|
6364
|
+
return;
|
|
6365
|
+
}
|
|
6252
6366
|
if (ev.shiftKey && selectable !== 'single' && anchorPos !== -1) {
|
|
6253
6367
|
// Shift: the range between the fixed anchor and the moving
|
|
6254
6368
|
// row, as Shift-click selects it.
|
|
@@ -6268,6 +6382,26 @@ export class DataTable {
|
|
|
6268
6382
|
this._disposers.push(() => table.removeEventListener('keydown', onNavKey));
|
|
6269
6383
|
}
|
|
6270
6384
|
|
|
6385
|
+
// STORY-0016. Space ticks the keyboard's row in select mode.
|
|
6386
|
+
if (this._gutter) {
|
|
6387
|
+
const onSpace = (ev) => {
|
|
6388
|
+
if (!this._selecting || ev.key !== ' ' || ev.ctrlKey || ev.metaKey || ev.altKey) return;
|
|
6389
|
+
if (isRowControl(ev.target, table)) return;
|
|
6390
|
+
const idx = this._navCursor ?? this._state.anchorIndex;
|
|
6391
|
+
if (idx == null || !this.config.rows[idx]) return;
|
|
6392
|
+
ev.preventDefault();
|
|
6393
|
+
const selected = this._state.selected;
|
|
6394
|
+
if (selected.has(idx)) selected.delete(idx); else selected.add(idx);
|
|
6395
|
+
this._state.anchorIndex = idx;
|
|
6396
|
+
this._updateRowSelection();
|
|
6397
|
+
this._notifySelectionChange();
|
|
6398
|
+
};
|
|
6399
|
+
table.addEventListener('keydown', onSpace);
|
|
6400
|
+
this._disposers.push(() => table.removeEventListener('keydown', onSpace));
|
|
6401
|
+
this._paintSelectHead();
|
|
6402
|
+
this._markCursor();
|
|
6403
|
+
}
|
|
6404
|
+
|
|
6271
6405
|
// 0.5.0 `onRowActivate`. Bound AFTER the selection handler on the same
|
|
6272
6406
|
// element, so a row is selected before it is opened — the listener
|
|
6273
6407
|
// order is the guarantee. `onRowClick` stays exactly where it was (on
|
|
@@ -6315,6 +6449,9 @@ export class DataTable {
|
|
|
6315
6449
|
const idx = rowEl.__rowIndex;
|
|
6316
6450
|
let custom = null;
|
|
6317
6451
|
if (customMenu) {
|
|
6452
|
+
// STORY-0016. In select mode the menu is about the ticked
|
|
6453
|
+
// rows when the row is one of them, else about that row
|
|
6454
|
+
// alone — and the ticks stay as they were.
|
|
6318
6455
|
const selection = idx !== undefined && !this._state.selected.has(idx)
|
|
6319
6456
|
? [idx] : this.getSelection();
|
|
6320
6457
|
const ctx = this._contextMenuCtxFor(rowEl, event.target, selection);
|
|
@@ -6328,7 +6465,7 @@ export class DataTable {
|
|
|
6328
6465
|
event.preventDefault();
|
|
6329
6466
|
event.stopPropagation();
|
|
6330
6467
|
|
|
6331
|
-
if (idx !== undefined && !this._state.selected.has(idx)) {
|
|
6468
|
+
if (idx !== undefined && !this._state.selected.has(idx) && !this._selecting) {
|
|
6332
6469
|
this._state.selected.clear();
|
|
6333
6470
|
this._state.selected.add(idx);
|
|
6334
6471
|
this._state.anchorIndex = idx;
|
|
@@ -6356,21 +6493,7 @@ export class DataTable {
|
|
|
6356
6493
|
event.preventDefault();
|
|
6357
6494
|
} else if (key === 'a' && selectable) {
|
|
6358
6495
|
event.preventDefault();
|
|
6359
|
-
this.
|
|
6360
|
-
// Select only visible (filtered) rows via index map
|
|
6361
|
-
const indexMap = this._processedIndexMap;
|
|
6362
|
-
if (indexMap) {
|
|
6363
|
-
for (const originalIdx of indexMap) {
|
|
6364
|
-
this._state.selected.add(originalIdx);
|
|
6365
|
-
}
|
|
6366
|
-
} else {
|
|
6367
|
-
for (let i = 0; i < this.config.rows.length; i++) {
|
|
6368
|
-
this._state.selected.add(i);
|
|
6369
|
-
}
|
|
6370
|
-
}
|
|
6371
|
-
this._state.anchorIndex = this.config.rows.length - 1;
|
|
6372
|
-
this._updateRowSelection();
|
|
6373
|
-
this._notifySelectionChange();
|
|
6496
|
+
this._selectAllShown();
|
|
6374
6497
|
} else if (key === 'c') {
|
|
6375
6498
|
event.preventDefault();
|
|
6376
6499
|
this.copyToClipboard(event.shiftKey ? 'csv' : 'tsv');
|
|
@@ -6378,6 +6501,8 @@ export class DataTable {
|
|
|
6378
6501
|
} else if (event.key === 'Escape') {
|
|
6379
6502
|
if (this._state.selected.size) {
|
|
6380
6503
|
this.clearSelection();
|
|
6504
|
+
} else if (this._selecting && this.config.selectToggle) {
|
|
6505
|
+
this.setSelectMode(false); // STORY-0016
|
|
6381
6506
|
}
|
|
6382
6507
|
this._hideContextMenu();
|
|
6383
6508
|
}
|
|
@@ -6393,9 +6518,119 @@ export class DataTable {
|
|
|
6393
6518
|
this._tbodyEl.querySelectorAll('tr').forEach(tr => {
|
|
6394
6519
|
const idx = tr.__rowIndex;
|
|
6395
6520
|
if (idx !== undefined) {
|
|
6396
|
-
|
|
6521
|
+
const on = this._state.selected.has(idx);
|
|
6522
|
+
tr.classList.toggle('selected', on);
|
|
6523
|
+
// STORY-0016.
|
|
6524
|
+
const box = this._gutter ? tr.querySelector(':scope > td.twm-dt-select-cell > input') : null;
|
|
6525
|
+
if (box) box.checked = on;
|
|
6397
6526
|
}
|
|
6398
6527
|
});
|
|
6528
|
+
this._paintSelectHead();
|
|
6529
|
+
}
|
|
6530
|
+
|
|
6531
|
+
/* ── STORY-0016: select mode ─────────────────────────────────────── */
|
|
6532
|
+
|
|
6533
|
+
/** Whether the table is in select mode. */
|
|
6534
|
+
isSelectMode() {
|
|
6535
|
+
return !!this._selecting;
|
|
6536
|
+
}
|
|
6537
|
+
|
|
6538
|
+
/**
|
|
6539
|
+
* Turn select mode on or off. The ticks are kept either way (a selection
|
|
6540
|
+
* is a selection); the consumer clears it if leaving means "never mind".
|
|
6541
|
+
* Does nothing on a table without the select column.
|
|
6542
|
+
*
|
|
6543
|
+
* @param {boolean} on
|
|
6544
|
+
* @returns {boolean} whether the mode changed
|
|
6545
|
+
*/
|
|
6546
|
+
setSelectMode(on) {
|
|
6547
|
+
if (!this._gutter || !!on === this._selecting) return false;
|
|
6548
|
+
this._selecting = !!on;
|
|
6549
|
+
this._navCursor = this._selecting ? this._state.anchorIndex : null;
|
|
6550
|
+
if (this._wrapperEl) this.render();
|
|
6551
|
+
try { this.config.onSelectModeChange?.(this._selecting); } catch (err) {
|
|
6552
|
+
console.error('[DataTable] onSelectModeChange threw', err);
|
|
6553
|
+
}
|
|
6554
|
+
return true;
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6557
|
+
/** The rows the filters let through, on every page, by original index. */
|
|
6558
|
+
_shownIndices() {
|
|
6559
|
+
if (this._isServerSide()) return this.config.rows.map((_, i) => i);
|
|
6560
|
+
this._getProcessedRows();
|
|
6561
|
+
return this._processedIndexMap ? [...this._processedIndexMap] : this.config.rows.map((_, i) => i);
|
|
6562
|
+
}
|
|
6563
|
+
|
|
6564
|
+
/** Ctrl+A and the select-all box: every row the filters let through. */
|
|
6565
|
+
_selectAllShown() {
|
|
6566
|
+
this._state.selected.clear();
|
|
6567
|
+
for (const i of this._shownIndices()) this._state.selected.add(i);
|
|
6568
|
+
this._state.anchorIndex = this.config.rows.length - 1;
|
|
6569
|
+
this._updateRowSelection();
|
|
6570
|
+
this._notifySelectionChange();
|
|
6571
|
+
}
|
|
6572
|
+
|
|
6573
|
+
/** The select column's width: its controls, never a drag. */
|
|
6574
|
+
_selectColWidth() {
|
|
6575
|
+
return this._selecting ? (this.config.selectToggle ? 56 : 32) : 32;
|
|
6576
|
+
}
|
|
6577
|
+
|
|
6578
|
+
/** The select column's header: the toggle and, in the mode, select-all. */
|
|
6579
|
+
_buildSelectHead() {
|
|
6580
|
+
const th = document.createElement('th');
|
|
6581
|
+
th.className = 'twm-dt-select-cell twm-dt-select-head';
|
|
6582
|
+
if (this._selecting) {
|
|
6583
|
+
const all = document.createElement('input');
|
|
6584
|
+
all.type = 'checkbox';
|
|
6585
|
+
all.className = 'twm-dt-select-all';
|
|
6586
|
+
all.setAttribute('aria-label', 'Select all rows');
|
|
6587
|
+
all.title = 'Select all rows';
|
|
6588
|
+
all.addEventListener('click', (ev) => {
|
|
6589
|
+
// Not prevented: the browser would undo the state set here.
|
|
6590
|
+
ev.stopPropagation();
|
|
6591
|
+
const shown = this._shownIndices();
|
|
6592
|
+
const every = shown.length > 0 && shown.every((i) => this._state.selected.has(i));
|
|
6593
|
+
if (every) this.clearSelection(); else this._selectAllShown();
|
|
6594
|
+
});
|
|
6595
|
+
th.appendChild(all);
|
|
6596
|
+
}
|
|
6597
|
+
if (this.config.selectToggle) {
|
|
6598
|
+
const btn = document.createElement('button');
|
|
6599
|
+
btn.type = 'button';
|
|
6600
|
+
btn.className = 'material-symbols-outlined twm-dt-select-toggle';
|
|
6601
|
+
btn.textContent = this._selecting ? 'close' : 'checklist';
|
|
6602
|
+
btn.setAttribute('aria-pressed', this._selecting ? 'true' : 'false');
|
|
6603
|
+
const label = this._selecting ? 'Stop selecting' : 'Select multiple';
|
|
6604
|
+
btn.setAttribute('aria-label', label);
|
|
6605
|
+
btn.title = label;
|
|
6606
|
+
btn.addEventListener('click', (ev) => {
|
|
6607
|
+
ev.stopPropagation();
|
|
6608
|
+
this.setSelectMode(!this._selecting);
|
|
6609
|
+
});
|
|
6610
|
+
th.appendChild(btn);
|
|
6611
|
+
}
|
|
6612
|
+
th.classList.toggle('twm-dt-select-head--on', this._selecting);
|
|
6613
|
+
return th;
|
|
6614
|
+
}
|
|
6615
|
+
|
|
6616
|
+
/** The select-all box: checked, mixed or clear, as the ticks are. */
|
|
6617
|
+
_paintSelectHead() {
|
|
6618
|
+
if (!this._gutter || !this._wrapperEl) return;
|
|
6619
|
+
const all = this._wrapperEl.querySelector('.twm-dt-select-all');
|
|
6620
|
+
if (!all) return;
|
|
6621
|
+
const shown = this._shownIndices();
|
|
6622
|
+
const n = shown.filter((i) => this._state.selected.has(i)).length;
|
|
6623
|
+
all.checked = n > 0 && n === shown.length;
|
|
6624
|
+
all.indeterminate = n > 0 && n < shown.length;
|
|
6625
|
+
}
|
|
6626
|
+
|
|
6627
|
+
/** The keyboard's row in select mode, marked so it can be seen. */
|
|
6628
|
+
_markCursor() {
|
|
6629
|
+
if (!this._tbodyEl) return;
|
|
6630
|
+
for (const tr of this._tbodyEl.children) {
|
|
6631
|
+
if (tr.__rowIndex === undefined) continue;
|
|
6632
|
+
tr.classList.toggle('twm-dt-row--cursor', this._selecting && tr.__rowIndex === this._navCursor);
|
|
6633
|
+
}
|
|
6399
6634
|
}
|
|
6400
6635
|
|
|
6401
6636
|
_notifySelectionChange() {
|
|
@@ -116,7 +116,9 @@ export function openForm({ title, fields = [], defaults = {}, submitLabel = 'OK'
|
|
|
116
116
|
body.className = 'twm-modal__body-host';
|
|
117
117
|
body.innerHTML = `
|
|
118
118
|
<form class="twm-modal__form">
|
|
119
|
-
|
|
119
|
+
<div class="twm-modal__fields">
|
|
120
|
+
${fields.map((f) => _renderEntry(f, defaults[f.name] ?? f.default)).join('')}
|
|
121
|
+
</div>
|
|
120
122
|
<div class="twm-modal__error" data-role="form-error" hidden></div>
|
|
121
123
|
<div class="twm-modal__actions">
|
|
122
124
|
<button type="button" class="twm-btn" data-action="cancel">Cancel</button>
|
|
@@ -700,7 +702,7 @@ function _estimateHeight(fields) {
|
|
|
700
702
|
body += 32 + rows * 18;
|
|
701
703
|
if (f.hint) body += 16;
|
|
702
704
|
}
|
|
703
|
-
else if (f.type === 'checkbox') body += 32;
|
|
705
|
+
else if (f.type === 'checkbox') { body += 32; if (f.hint) body += 16; }
|
|
704
706
|
else { body += 46; if (f.hint) body += 16; }
|
|
705
707
|
}
|
|
706
708
|
const base = 36 + 46 + 28 + body;
|