flexdesk 0.4.6 → 0.5.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 +140 -0
- package/css/base.css +123 -2
- package/css/flexdesk.css +127 -4
- package/css/overrides.css +4 -2
- package/css/tokens.css +14 -0
- package/dist/charts.js +3 -3
- package/dist/{chunk-ELXVW542.js → chunk-24MS7W5Y.js} +2 -2
- package/dist/{chunk-LH5TSOZW.js → chunk-6JSOVNID.js} +42 -2
- package/dist/{chunk-LH5TSOZW.js.map → chunk-6JSOVNID.js.map} +3 -3
- package/dist/{chunk-HWCX5I4R.js → chunk-CNXP3SIN.js} +3 -3
- package/dist/{chunk-CYTWMP2Y.js → chunk-KH2GJTKR.js} +1112 -32
- package/dist/chunk-KH2GJTKR.js.map +7 -0
- package/dist/flexdesk.css +127 -4
- package/dist/tiles.js +3 -3
- package/dist/tokens.css +14 -0
- package/dist/widgets.js +31 -5
- package/dist/widgets.js.map +1 -1
- package/dist/wm.js +79 -19
- package/dist/wm.js.map +3 -3
- package/package.json +1 -1
- package/src/tiling/landing_table.js +13 -5
- package/src/tiling/tile_breadcrumb.js +21 -0
- package/src/tiling/tile_renderer.js +11 -0
- package/src/tiling/wm.js +62 -18
- package/src/ui/components/data_table.js +961 -37
- package/src/ui/components/row_activation.js +70 -0
- package/src/ui/components/table_values.js +423 -0
- package/dist/chunk-CYTWMP2Y.js.map +0 -7
- /package/dist/{chunk-ELXVW542.js.map → chunk-24MS7W5Y.js.map} +0 -0
- /package/dist/{chunk-HWCX5I4R.js.map → chunk-CNXP3SIN.js.map} +0 -0
|
@@ -19,9 +19,144 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { createRafResizeObserver } from '../utils/raf_resize_observer.js';
|
|
22
|
+
import {
|
|
23
|
+
ISO_DATE, ISO_DATETIME, formatDate, formatDuration, matchDateFilter,
|
|
24
|
+
matchDurationFilter, parseDateValue, parseDuration,
|
|
25
|
+
} from './table_values.js';
|
|
26
|
+
import { isRowActivation, isRowControl } from './row_activation.js';
|
|
27
|
+
|
|
28
|
+
// 0.5.0: the value helpers a typed column uses, and the one row-activation rule,
|
|
29
|
+
// re-exported so a consumer reaches them where it reaches DataTable.
|
|
30
|
+
export {
|
|
31
|
+
ISO_DATE, ISO_DATETIME, formatDate, formatDuration, parseDateValue,
|
|
32
|
+
parseDatePeriod, parseDuration, matchDateFilter, matchDurationFilter,
|
|
33
|
+
} from './table_values.js';
|
|
34
|
+
export {
|
|
35
|
+
ROW_CONTROL_SELECTOR, endsTextSelection, isRowActivation, isRowControl,
|
|
36
|
+
} from './row_activation.js';
|
|
22
37
|
|
|
23
38
|
const DEFAULT_PAGE_SIZE = 100;
|
|
24
39
|
|
|
40
|
+
/** 0.5.0 — the column types that sort and filter by a value rather than by
|
|
41
|
+
* the text drawn for it. */
|
|
42
|
+
const TYPED_COLUMNS = new Set(['date', 'datetime', 'duration']);
|
|
43
|
+
|
|
44
|
+
/** 0.5.0. The clicks with which a `selectable: 'single'` table took its row
|
|
45
|
+
* back OFF the selection (Ctrl on the chosen row). `onRowActivate` does not
|
|
46
|
+
* open a row on such a click. Marked on the EVENT, not read back from the
|
|
47
|
+
* selection, because a selection callback that re-draws with new rows clears
|
|
48
|
+
* the selection before the activation listener runs. */
|
|
49
|
+
const DESELECTING_CLICKS = new WeakSet();
|
|
50
|
+
|
|
51
|
+
// ── 0.5.0 `autoDispose` ─────────────────────────────────────────────────────
|
|
52
|
+
// ONE MutationObserver per document, shared by every table that asked, rather
|
|
53
|
+
// than one per table: a page with forty lists would otherwise run forty
|
|
54
|
+
// callbacks on every mutation anywhere in it. Each callback asks each table one
|
|
55
|
+
// question (`isConnected`), and the observer disconnects itself when the last
|
|
56
|
+
// table is gone.
|
|
57
|
+
//
|
|
58
|
+
// The tables are held WEAKLY. A table rendered into a host that never reaches
|
|
59
|
+
// the page is never "taken out" of it, so it is never disposed — and a strong
|
|
60
|
+
// set here would then keep it, its rows and its DOM alive for the life of the
|
|
61
|
+
// page: the very leak the option exists to stop.
|
|
62
|
+
const AUTO_DISPOSE = new Map(); // Document -> { observer, tables: Set<{deref()}> }
|
|
63
|
+
const AUTO_REFS = new WeakMap(); // DataTable -> its entry in that set
|
|
64
|
+
const weakRef = (table) => (typeof WeakRef === 'function'
|
|
65
|
+
? new WeakRef(table) : { deref: () => table });
|
|
66
|
+
|
|
67
|
+
function autoDisposeSweep(entry) {
|
|
68
|
+
for (const ref of entry.tables) {
|
|
69
|
+
const table = ref.deref();
|
|
70
|
+
if (!table) { entry.tables.delete(ref); continue; }
|
|
71
|
+
const el = table._wrapperEl;
|
|
72
|
+
if (!el) continue;
|
|
73
|
+
if (el.isConnected) { table._autoSeen = true; continue; }
|
|
74
|
+
if (!table._autoSeen || table._autoPending) continue;
|
|
75
|
+
// NOT AT ONCE. A renderer that takes a subtree out and puts it back in
|
|
76
|
+
// the same task (FlexDesk's own tile renderer re-appends every leaf in
|
|
77
|
+
// tree order) has put it back by the time this microtask runs — and one
|
|
78
|
+
// that takes a turn longer still gets one task's grace. Only a table
|
|
79
|
+
// that is STILL out after that has been removed from the page.
|
|
80
|
+
table._autoPending = true;
|
|
81
|
+
setTimeout(() => {
|
|
82
|
+
table._autoPending = false;
|
|
83
|
+
if (!entry.tables.has(ref)) return;
|
|
84
|
+
const now = table._wrapperEl;
|
|
85
|
+
// `_disposeOwn`, NOT `dispose()`: by now the host may hold
|
|
86
|
+
// something else — the next list mounted into it, or the pane's
|
|
87
|
+
// own message — and `dispose()` empties the host.
|
|
88
|
+
if (now && !now.isConnected) table._disposeOwn();
|
|
89
|
+
}, 0);
|
|
90
|
+
}
|
|
91
|
+
if (entry.tables.size === 0) autoDisposeRelease(entry);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function autoDisposeRelease(entry) {
|
|
95
|
+
for (const [doc, e] of AUTO_DISPOSE) {
|
|
96
|
+
if (e !== entry) continue;
|
|
97
|
+
try { e.observer.disconnect(); } catch (_) { /* gone */ }
|
|
98
|
+
AUTO_DISPOSE.delete(doc);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function autoDisposeWatch(table) {
|
|
103
|
+
const doc = table.container?.ownerDocument;
|
|
104
|
+
const MO = doc?.defaultView?.MutationObserver || globalThis.MutationObserver;
|
|
105
|
+
if (!doc || typeof MO !== 'function') return;
|
|
106
|
+
let entry = AUTO_DISPOSE.get(doc);
|
|
107
|
+
if (!entry) {
|
|
108
|
+
entry = { observer: null, tables: new Set() };
|
|
109
|
+
entry.observer = new MO(() => autoDisposeSweep(entry));
|
|
110
|
+
entry.observer.observe(doc, { childList: true, subtree: true });
|
|
111
|
+
AUTO_DISPOSE.set(doc, entry);
|
|
112
|
+
}
|
|
113
|
+
let ref = AUTO_REFS.get(table);
|
|
114
|
+
if (!ref) { ref = weakRef(table); AUTO_REFS.set(table, ref); }
|
|
115
|
+
entry.tables.add(ref);
|
|
116
|
+
if (table._wrapperEl?.isConnected) table._autoSeen = true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function autoDisposeUnwatch(table) {
|
|
120
|
+
const ref = AUTO_REFS.get(table);
|
|
121
|
+
if (!ref) return;
|
|
122
|
+
for (const entry of [...AUTO_DISPOSE.values()]) {
|
|
123
|
+
if (entry.tables.delete(ref) && entry.tables.size === 0) autoDisposeRelease(entry);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A per-column option: one value for every column, or an array / object
|
|
128
|
+
* keyed by column index. `null` and `undefined` mean "not set". */
|
|
129
|
+
function perColumn(option, colIdx) {
|
|
130
|
+
if (option == null) return null;
|
|
131
|
+
if (Array.isArray(option)) return option[colIdx] ?? null;
|
|
132
|
+
if (typeof option === 'object') return option[colIdx] ?? null;
|
|
133
|
+
return option;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** One sort key, comparable with `compareSortKeys`. */
|
|
137
|
+
function normaliseSortKey(k) {
|
|
138
|
+
if (k == null) return null;
|
|
139
|
+
if (k instanceof Date) k = k.getTime();
|
|
140
|
+
if (typeof k === 'boolean') return k ? 1 : 0;
|
|
141
|
+
if (typeof k === 'number') return Number.isFinite(k) ? k : null;
|
|
142
|
+
if (typeof k === 'bigint') return Number(k);
|
|
143
|
+
return String(k).toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Nulls (and NaN) last in either direction — as the 0.4 comparator put them —
|
|
147
|
+
* numbers before text, numbers numerically, text as lower-cased text. */
|
|
148
|
+
function compareSortKeys(a, b, asc) {
|
|
149
|
+
if (a === null && b === null) return 0;
|
|
150
|
+
if (a === null) return 1;
|
|
151
|
+
if (b === null) return -1;
|
|
152
|
+
const na = typeof a === 'number';
|
|
153
|
+
const nb = typeof b === 'number';
|
|
154
|
+
let cmp;
|
|
155
|
+
if (na !== nb) cmp = na ? -1 : 1;
|
|
156
|
+
else cmp = a < b ? -1 : a > b ? 1 : 0;
|
|
157
|
+
return asc ? cmp : -cmp;
|
|
158
|
+
}
|
|
159
|
+
|
|
25
160
|
/** How wide auto-sizing a column is allowed to make it.
|
|
26
161
|
*
|
|
27
162
|
* 520px, which is `_fitColumnWidths`' FIRST_CAP — the most generous ceiling
|
|
@@ -39,18 +174,19 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
39
174
|
* @property {Array<Array>} rows - Row data (array of arrays)
|
|
40
175
|
* @property {number} [pageSize=100] - Rows per page
|
|
41
176
|
* @property {boolean} [pagination=true] - Enable pagination controls
|
|
42
|
-
* @property {boolean} [selectable=true] - Enable row selection
|
|
177
|
+
* @property {boolean|'single'} [selectable=true] - Enable row selection. 'single' (0.5.0): a click selects exactly one row, Shift and Ctrl do not extend it, Ctrl+A selects nothing, and `setSelection` keeps the last index it is given.
|
|
43
178
|
* @property {boolean} [copyable=true] - Enable copy shortcuts and context menu
|
|
44
179
|
* @property {boolean} [sortable=false] - Enable column sorting
|
|
45
180
|
* @property {boolean} [filterable=false] - Enable per-column filter inputs
|
|
46
181
|
* @property {boolean} [readonly=false] - Read-only mode (no selection/hover effects)
|
|
47
|
-
* @property {boolean} [showRowNumbers=false] - Show row number column
|
|
182
|
+
* @property {boolean|'position'} [showRowNumbers=false] - Show row number column. `true` numbers a row by its ORIGINAL index (2, 3, 4, 1 after a sort); 'position' (0.5.0) numbers the rows as SHOWN — 1, 2, 3 after a sort, a filter, or on any page.
|
|
48
183
|
* @property {string} [emptyMessage='No data'] - Message when no data
|
|
49
184
|
* @property {Function} [onSort] - Callback when sort changes: (column, ascending) => void
|
|
185
|
+
* @property {boolean} [resetPageOnSort=false] - A client-paged table goes back to its first page on every `sortBy()` (a header click included), the way a new filter already does: on page 3, a new order otherwise draws rows 201–300 OF THE NEW ORDER under "Page 3 of 3", which reads as "it only sorted this page". OFF by default: before this key existed a sort kept the page, and a table that does not ask keeps that. Server-side paging is untouched either way — its page is the consumer's `offset`, which `sortBy` never reads or writes.
|
|
50
186
|
* @property {Function} [onSelectionChange] - Callback when selection changes: (selectedIndices) => void
|
|
51
187
|
* @property {Function} [formatValue] - Custom value formatter: (value, colIndex) => string
|
|
52
188
|
* @property {Function} [getHeaderIcon] - Get icon for header: (header, colIndex) => {icon, title}
|
|
53
|
-
* @property {Function} [getColumnType] - Get column type: (colIndex, rows) => 'num'|'text'
|
|
189
|
+
* @property {Function} [getColumnType] - Get column type: (colIndex, rows) => 'num'|'text'. What it returns is the cells' class name, and 'num' also sorts and filters as a number — exactly as in 0.4. A date, datetime or duration column is declared through `columnTypes` (0.5.0): returned from here, those names are class names only.
|
|
54
190
|
* @property {Object} [services] - App services { eventBus, logger } for notifications
|
|
55
191
|
* @property {number} [totalCount] - Total row count for server-side pagination (when rows only contains current page)
|
|
56
192
|
* @property {number} [offset=0] - Current offset for server-side pagination
|
|
@@ -68,6 +204,29 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
68
204
|
* @property {boolean|{overscan?: number, rowHeight?: number}} [virtualize=false] - C10. Draw only the rows near the viewport, between a top and a bottom spacer row, and give the body table a `<colgroup>` so its column widths no longer come from whichever row happens to be first. OFF by default: nothing about a table that does not ask changes. `overscan` (rows drawn beyond each edge, default 20) and `rowHeight` (the estimate used until a real row has been measured, default 24) tune it. See `_virtUpdate` for the whole contract.
|
|
69
205
|
* @property {Function} [virtualRowHeight] - `(rowIndex, baseHeight) => number`. Under `virtualize`, the height a row occupies, including anything the embedder injects in front of it (a group header) — and 0 for a row the embedder hides, which is then not drawn at all. Absent: every row is `baseHeight`, which is measured from a real row.
|
|
70
206
|
* @property {Function} [onRowsRendered] - `({ rows, removed }) => void`. Under `virtualize`, called whenever the window changes after `render()` returned: `rows` are the `<tr>`s the virtualiser ADDED — the rows an embedder that decorates rows after a render would otherwise never see — and `removed` the ones it took OUT, already detached (an embedder that injected rows of its own beside them, or holds an editor in one, has to hear that they went). The rows present when `render()` returns are not reported: they are the render's, exactly as without `virtualize`.
|
|
207
|
+
*
|
|
208
|
+
* ── 0.5.0, every one of them off unless asked for ─────────────────────────
|
|
209
|
+
* @property {boolean} [fitContent=false] - The table is as tall as its rows: the wrapper is `height:auto` instead of `height:100%`, and the body grows with its rows instead of filling the box. For a list in a flowing page, whose parent has no height — where the default draws a header and NO rows.
|
|
210
|
+
* @property {number|string} [maxHeight] - Implies `fitContent`, and caps the whole table at this height (a number is px); past it the body scrolls under its header.
|
|
211
|
+
* @property {boolean} [autoDispose=false] - Dispose by itself once the table has been taken out of the document (and is still out a task later). Unlike `dispose()`, it removes only the table's own elements and never empties the host, which by then may hold the next list or the pane's own message. Do NOT set it on a table you take out and put back later — a cached tab — since a disposed table comes back empty.
|
|
212
|
+
* @property {'time'|'plain'} [firstColumn='time'] - 'plain' draws the first column like every other column: no 120–150px pin, no grey, no weight. The default keeps the time-series styling the component was built around.
|
|
213
|
+
* @property {boolean} [clickable] - Draw body rows as things to press (`twm-dt--clickable`: a pointer and a hover, readonly tables included). Defaults to on exactly when `onRowActivate` is set.
|
|
214
|
+
* @property {Function} [onRowActivate] - `(rowIdx, row, ev) => void`. The row OPENS. Fired after the selection has been updated, on the gestures `activateOn` names, and never on a press on a control inside the row, a click that ends a text selection in the table, the second click of a double-click, or (`selectable: 'single'`) the Ctrl click that took the row off the selection. `ev.type` says which gesture ('click', 'dblclick' or 'keydown'). `rowIdx` is the original index, as for `onRowClick`. A press whose `onSelectionChange` or `onRowClick` re-draws the table still opens the row pressed; `row` is that row as it was drawn.
|
|
215
|
+
* @property {'click'|'dblclick'|'enter'|Array<'click'|'dblclick'|'enter'>} [activateOn='click'] - Which gestures open a row. A list of things to open: 'click'. A pick list, where one click selects: ['dblclick', 'enter']. Enter opens the selected row (the one row selected, or the selection's anchor) — the row a double-click on it would open; with nothing selected, the active row (`activeRow`).
|
|
216
|
+
* @property {Function} [getRowKey] - `(row, rowIdx) => key`. A row's identity, which `activeRow` is matched by. Compared as text.
|
|
217
|
+
* @property {*} [activeRow] - The key of the row that is OPEN (the master of a master-detail): drawn `twm-dt-row--active` with `aria-current="true"`. It survives `setData`, a sort, a filter and a page, and a right-click does not move it. Without `getRowKey` the key is the row's original index. See `setActiveRow`.
|
|
218
|
+
* @property {Function} [rowClass] - `(row, rowIdx) => string|string[]|null`. Classes for the row's `<tr>`.
|
|
219
|
+
* @property {Function} [rowAttrs] - `(row, rowIdx) => {name: value}`. Attributes for the row's `<tr>` (`null`/`false` leaves one off, `true` sets it empty).
|
|
220
|
+
* @property {Function} [cellClass] - `(value, colIdx, row, rowIdx) => string|string[]|null`. Classes for a cell, after its type class.
|
|
221
|
+
* @property {Function} [rowIcon] - `(row, rowIdx) => string|{icon, title?, tone?}|null`. A Material Symbols icon at the start of the row's first cell; `tone` adds `twm-dt-row-icon--<tone>`. Drawn by CSS, so it is in neither the cell's text, its tooltip nor a copy.
|
|
222
|
+
* @property {string|Node|Function} [emptyState] - What the box says when there are no rows: text, a node, or a function returning either (called on each render). Replaces `emptyMessage`, and is hidden while `setLoading(true)` — an empty table that is still loading is not empty.
|
|
223
|
+
* @property {string} [nullDisplay] - Draw `null`/`undefined` as this text (and copy it so), with `twm-dt-cell--null` on the cell, instead of '-'.
|
|
224
|
+
* @property {Array<string|null>} [columnTypes] - Each column's type by position ('num', 'text', 'date', 'datetime', 'duration'); `null` leaves a column to `getColumnType` and detection. Only a type declared HERE makes a column a date, datetime or duration column — drawn, sorted and filtered by its value; `setData({columnTypes})` applies a new list at once.
|
|
225
|
+
* @property {Function|Object|Array} [sortValue] - `(value, colIdx, row) => comparable` — what a column sorts by, for every column or (as an object or array keyed by column index) for some. `undefined` falls back to the column's own rule. A `Date` sorts by its time, numbers numerically, text as lower-cased text, `null` last.
|
|
226
|
+
* @property {string|Function|Object|Array} [dateFormat='YYYY-MM-DD'] - How a 'date' column is drawn: a pattern (see `formatDate`) or `(date) => string`, for every date column or per column index.
|
|
227
|
+
* @property {string|Function|Object|Array} [dateTimeFormat='YYYY-MM-DD HH:mm'] - The same for a 'datetime' column.
|
|
228
|
+
* @property {'auto'|'clock'|Function|Object|Array} [durationFormat='auto'] - How a 'duration' column (milliseconds) is drawn: '850 ms', '3.2 s', '2m 5s' (auto) or '0:02:05' (clock).
|
|
229
|
+
* @property {'local'|'UTC'} [dateTimeZone='local'] - The zone typed date columns are read and drawn in.
|
|
71
230
|
*/
|
|
72
231
|
|
|
73
232
|
/** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
|
|
@@ -96,6 +255,11 @@ export class DataTable {
|
|
|
96
255
|
showRowNumbers: false,
|
|
97
256
|
emptyMessage: 'No data',
|
|
98
257
|
onSort: null,
|
|
258
|
+
// OFF unless asked for, for `columnFit`'s reason below: a sort kept
|
|
259
|
+
// the client-side page before this key existed, so turning the
|
|
260
|
+
// reset on for everybody would change every paged table that never
|
|
261
|
+
// asked. See the typedef.
|
|
262
|
+
resetPageOnSort: false,
|
|
99
263
|
onSelectionChange: null,
|
|
100
264
|
formatValue: null,
|
|
101
265
|
getHeaderIcon: null,
|
|
@@ -134,9 +298,37 @@ export class DataTable {
|
|
|
134
298
|
virtualize: false,
|
|
135
299
|
virtualRowHeight: null,
|
|
136
300
|
onRowsRendered: null,
|
|
301
|
+
// 0.5.0. Every key below is off — or exactly the 0.4 behaviour — until
|
|
302
|
+
// a consumer sets it (D6: additive and back-compatible). The typedef
|
|
303
|
+
// says what each does.
|
|
304
|
+
fitContent: false,
|
|
305
|
+
maxHeight: null,
|
|
306
|
+
autoDispose: false,
|
|
307
|
+
firstColumn: 'time',
|
|
308
|
+
clickable: null,
|
|
309
|
+
onRowActivate: null,
|
|
310
|
+
activateOn: 'click',
|
|
311
|
+
getRowKey: null,
|
|
312
|
+
activeRow: null,
|
|
313
|
+
rowClass: null,
|
|
314
|
+
rowAttrs: null,
|
|
315
|
+
cellClass: null,
|
|
316
|
+
rowIcon: null,
|
|
317
|
+
emptyState: undefined,
|
|
318
|
+
nullDisplay: null,
|
|
319
|
+
columnTypes: null,
|
|
320
|
+
sortValue: null,
|
|
321
|
+
dateFormat: null,
|
|
322
|
+
dateTimeFormat: null,
|
|
323
|
+
durationFormat: null,
|
|
324
|
+
dateTimeZone: 'local',
|
|
137
325
|
...config,
|
|
138
326
|
};
|
|
139
327
|
|
|
328
|
+
// 0.5.0. The open row's key, and the failure on show (`setError`).
|
|
329
|
+
this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
|
|
330
|
+
this._error = null;
|
|
331
|
+
|
|
140
332
|
// C10. The render window, or null when this table draws every row.
|
|
141
333
|
this._virt = null;
|
|
142
334
|
// The measured row height outlives a render, so a re-render does not
|
|
@@ -186,6 +378,9 @@ export class DataTable {
|
|
|
186
378
|
// Event cleanup
|
|
187
379
|
this._disposers = [];
|
|
188
380
|
this._contextMenuGlobals = [];
|
|
381
|
+
// 0.5.0. `onRowActivate`'s listeners are kept apart from `_disposers`:
|
|
382
|
+
// a render does not remove them at once (see `_retireActivation`).
|
|
383
|
+
this._activationOffs = [];
|
|
189
384
|
|
|
190
385
|
// Persistence: seed from whatever's already cached, then — once
|
|
191
386
|
// the on-disk blob has loaded — re-apply and re-render if state
|
|
@@ -203,6 +398,46 @@ export class DataTable {
|
|
|
203
398
|
}
|
|
204
399
|
}
|
|
205
400
|
|
|
401
|
+
/**
|
|
402
|
+
* 0.5.0. Construct AND DRAW, in one call. The constructor draws nothing
|
|
403
|
+
* until `render()`, so every consumer that forgot the second line got an
|
|
404
|
+
* empty box; this is the two lines, once.
|
|
405
|
+
*
|
|
406
|
+
* Called on a class made by `withDefaults`, it builds that class — so the
|
|
407
|
+
* house defaults apply.
|
|
408
|
+
*
|
|
409
|
+
* @param {HTMLElement} container
|
|
410
|
+
* @param {DataTableConfig} [config]
|
|
411
|
+
* @returns {DataTable}
|
|
412
|
+
*/
|
|
413
|
+
static mount(container, config = {}) {
|
|
414
|
+
const table = new this(container, config);
|
|
415
|
+
table.render();
|
|
416
|
+
return table;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 0.5.0. A DataTable class with house defaults: `defaults` sit under every
|
|
421
|
+
* config it is constructed (or `mount`ed) with, and the config wins key by
|
|
422
|
+
* key. It is a real subclass — `instanceof DataTable` holds — and it can be
|
|
423
|
+
* narrowed again with its own `withDefaults`.
|
|
424
|
+
*
|
|
425
|
+
* const ListTable = DataTable.withDefaults({ mode: 'compact', sortable: true,
|
|
426
|
+
* pagination: false, fitContent: true });
|
|
427
|
+
* const table = ListTable.mount(host, { headers, rows });
|
|
428
|
+
*
|
|
429
|
+
* @param {DataTableConfig} defaults
|
|
430
|
+
* @returns {typeof DataTable}
|
|
431
|
+
*/
|
|
432
|
+
static withDefaults(defaults = {}) {
|
|
433
|
+
const house = { ...defaults };
|
|
434
|
+
return class extends this {
|
|
435
|
+
constructor(container, config = {}) {
|
|
436
|
+
super(container, { ...house, ...config });
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
206
441
|
/** Apply a persisted blob ({sort, asc, filters, widths}) onto this
|
|
207
442
|
* table's live state. Returns true if anything was applied. */
|
|
208
443
|
_restorePersisted(blob) {
|
|
@@ -275,6 +510,19 @@ export class DataTable {
|
|
|
275
510
|
this._state.offset = 0;
|
|
276
511
|
}
|
|
277
512
|
this._columnTypes = this._detectColumnTypes();
|
|
513
|
+
// 0.5.0. New rows are an ANSWER, so a failure on show is over. The
|
|
514
|
+
// open row is not reset: it is a key, and is found again in these
|
|
515
|
+
// rows (or stays unmarked until it comes back).
|
|
516
|
+
this._error = null;
|
|
517
|
+
}
|
|
518
|
+
if (Object.prototype.hasOwnProperty.call(updates, 'activeRow')) {
|
|
519
|
+
this._activeKey = updates.activeRow == null ? null : String(updates.activeRow);
|
|
520
|
+
}
|
|
521
|
+
// 0.5.0. A new `columnTypes` list (null included) applies at once. A new
|
|
522
|
+
// `getColumnType` alone does NOT re-detect: in 0.4 it took effect with
|
|
523
|
+
// the next rows, and a table that asks for nothing new keeps that.
|
|
524
|
+
if (Object.prototype.hasOwnProperty.call(updates, 'columnTypes') && !updates.rows) {
|
|
525
|
+
this._columnTypes = this._detectColumnTypes();
|
|
278
526
|
}
|
|
279
527
|
|
|
280
528
|
// Invalidate processed cache
|
|
@@ -295,7 +543,14 @@ export class DataTable {
|
|
|
295
543
|
* @param {boolean} on
|
|
296
544
|
*/
|
|
297
545
|
setLoading(on) {
|
|
546
|
+
this._loading = !!on;
|
|
298
547
|
if (!this._wrapperEl) return;
|
|
548
|
+
// 0.5.0. A wrapper class as well as the overlay, so an `emptyState` can
|
|
549
|
+
// stay quiet while there is nothing YET (see the CSS) — and ONLY for a
|
|
550
|
+
// table with an `emptyState`, the one thing that reads it: a table that
|
|
551
|
+
// asks for nothing new keeps 0.4's wrapper to the attribute.
|
|
552
|
+
this._wrapperEl.classList.toggle('twm-data-table-component--loading',
|
|
553
|
+
!!on && this.config.emptyState !== undefined);
|
|
299
554
|
let overlay = this._wrapperEl.querySelector('.twm-data-table__loading');
|
|
300
555
|
if (on) {
|
|
301
556
|
if (!overlay) {
|
|
@@ -330,13 +585,89 @@ export class DataTable {
|
|
|
330
585
|
this._state.selected.clear();
|
|
331
586
|
for (const idx of indices) {
|
|
332
587
|
if (idx >= 0 && idx < this.config.rows.length) {
|
|
588
|
+
// 0.5.0. A single-selection table keeps the LAST index it is
|
|
589
|
+
// given — the most recent choice — never several.
|
|
590
|
+
if (this.config.selectable === 'single') this._state.selected.clear();
|
|
333
591
|
this._state.selected.add(idx);
|
|
334
592
|
}
|
|
335
593
|
}
|
|
594
|
+
if (this.config.selectable === 'single') {
|
|
595
|
+
this._state.anchorIndex = this._state.selected.size
|
|
596
|
+
? [...this._state.selected][0] : null;
|
|
597
|
+
}
|
|
336
598
|
this._updateRowSelection();
|
|
337
599
|
this._notifySelectionChange();
|
|
338
600
|
}
|
|
339
601
|
|
|
602
|
+
// ─────────────────────────────────────────────────────────────────
|
|
603
|
+
// 0.5.0 — the open row, the failure state, column widths
|
|
604
|
+
// ─────────────────────────────────────────────────────────────────
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Mark the row that is OPEN — the master of a master-detail — by its key
|
|
608
|
+
* (`getRowKey`; without it, the row's original index). `null` clears it.
|
|
609
|
+
*
|
|
610
|
+
* The mark is NOT the selection: `setData` keeps it (the key is looked for
|
|
611
|
+
* in the new rows), a right-click does not move it, and a sort, a filter or
|
|
612
|
+
* a page change finds it where the row went. Drawn as `twm-dt-row--active`
|
|
613
|
+
* and `aria-current="true"` on the row, in place — no re-render.
|
|
614
|
+
*
|
|
615
|
+
* @param {*} key
|
|
616
|
+
*/
|
|
617
|
+
setActiveRow(key) {
|
|
618
|
+
this._activeKey = key == null ? null : String(key);
|
|
619
|
+
this.config.activeRow = key ?? null;
|
|
620
|
+
const tbody = this._tbodyEl;
|
|
621
|
+
if (!tbody) return;
|
|
622
|
+
for (const tr of tbody.children) {
|
|
623
|
+
if (tr.__rowIndex === undefined) continue;
|
|
624
|
+
this._paintActive(tr);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The open row's key, as text, or null. */
|
|
629
|
+
getActiveRow() {
|
|
630
|
+
return this._activeKey;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Show a FAILURE inside the table's own box: text, a node, or an `Error`
|
|
635
|
+
* (its message). The rows already drawn STAY — a failed refresh does not
|
|
636
|
+
* blank a list a person was reading — under a banner saying what failed;
|
|
637
|
+
* with no rows, the failure takes the empty row's place. `setError(null)`
|
|
638
|
+
* clears it, and so does `setData({rows})`: new rows are an answer. A
|
|
639
|
+
* failure also ends `setLoading`.
|
|
640
|
+
*
|
|
641
|
+
* @param {string|Node|Error|null} error
|
|
642
|
+
*/
|
|
643
|
+
setError(error) {
|
|
644
|
+
this._error = error == null || error === false ? null : error;
|
|
645
|
+
if (this._error) this.setLoading(false);
|
|
646
|
+
if (this._wrapperEl) this.render();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** What `setError` was last given, or null. */
|
|
650
|
+
getError() {
|
|
651
|
+
return this._error;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Forget every column width a person dragged, a double-click fitted or the
|
|
656
|
+
* store restored, and measure the columns again from what is on screen —
|
|
657
|
+
* for an embedder whose content has just changed size under them (a zoom,
|
|
658
|
+
* a font). Persisted widths are cleared too.
|
|
659
|
+
*
|
|
660
|
+
* @returns {boolean} whether there was a table to measure
|
|
661
|
+
*/
|
|
662
|
+
resetColumnWidths() {
|
|
663
|
+
this._colWidths = {};
|
|
664
|
+
this._colWidthsSig = this._colSig();
|
|
665
|
+
this._savePersisted();
|
|
666
|
+
if (!this._headerTableEl || !this._tableEl) return false;
|
|
667
|
+
this._syncHeaderWidths();
|
|
668
|
+
return true;
|
|
669
|
+
}
|
|
670
|
+
|
|
340
671
|
/**
|
|
341
672
|
* Clear selection
|
|
342
673
|
*/
|
|
@@ -385,6 +716,13 @@ export class DataTable {
|
|
|
385
716
|
// Invalidate processed cache
|
|
386
717
|
this._processedRows = null;
|
|
387
718
|
this._processedIndexMap = null;
|
|
719
|
+
// A new order starts at its first page — when the table asked for it.
|
|
720
|
+
// Staying on page 3 shows rows 201–300 of the NEW order, which reads as
|
|
721
|
+
// "it only sorted this page"; but that IS what a sort did before
|
|
722
|
+
// `resetPageOnSort` existed, so a table that does not ask keeps it (D6:
|
|
723
|
+
// additive and back-compatible). `render()` keeps the scroll position
|
|
724
|
+
// only on the same page, so the reset also starts the body at its top.
|
|
725
|
+
if (this.config.resetPageOnSort) this._state.offset = 0;
|
|
388
726
|
|
|
389
727
|
// Auto-spinner: if the sort handler returns a Promise (i.e.
|
|
390
728
|
// it does a server-side refetch), show the loading overlay
|
|
@@ -445,6 +783,7 @@ export class DataTable {
|
|
|
445
783
|
? { top: prevWrap.scrollTop, left: prevWrap.scrollLeft } : null;
|
|
446
784
|
|
|
447
785
|
this._cleanup();
|
|
786
|
+
this._retireActivation();
|
|
448
787
|
this.container.innerHTML = '';
|
|
449
788
|
// C10. A window belongs to one tbody; `_createTable` builds a new one.
|
|
450
789
|
this._virt = null;
|
|
@@ -473,7 +812,24 @@ export class DataTable {
|
|
|
473
812
|
this._wrapperEl = document.createElement('div');
|
|
474
813
|
this._wrapperEl.className = 'twm-data-table-component'
|
|
475
814
|
+ (this.config.mode === 'compact' ? ' twm-data-table-component--compact' : '');
|
|
476
|
-
|
|
815
|
+
// 0.5.0 `fitContent` / `maxHeight`. THE DEFAULT IS `height:100%`, which
|
|
816
|
+
// is right for a pane that already has a height and wrong for a list in
|
|
817
|
+
// a flowing page: against an `auto` parent it resolves to nothing, and
|
|
818
|
+
// the body (`flex:1 1 0`) gets ZERO — a header, a filter row, and every
|
|
819
|
+
// `<tr>` in the DOM with no box to draw in. A fitted table is as tall as
|
|
820
|
+
// its rows instead, up to `maxHeight`, past which its body scrolls.
|
|
821
|
+
const fit = this._fitsContent();
|
|
822
|
+
if (fit) {
|
|
823
|
+
this._wrapperEl.classList.add('twm-data-table-component--fit');
|
|
824
|
+
const max = this._maxHeightCss();
|
|
825
|
+
this._wrapperEl.style.cssText = 'display:flex; flex-direction:column; height:auto; min-height:0;'
|
|
826
|
+
+ (max ? ` max-height:${max};` : '');
|
|
827
|
+
} else {
|
|
828
|
+
this._wrapperEl.style.cssText = 'display:flex; flex-direction:column; height:100%; min-height:0;';
|
|
829
|
+
}
|
|
830
|
+
// A render ends a `setLoading` (the overlay goes with the old wrapper,
|
|
831
|
+
// as it always has).
|
|
832
|
+
this._loading = false;
|
|
477
833
|
|
|
478
834
|
// Pagination (top). `_createPagination` returns null when the
|
|
479
835
|
// strip would be uninformative (single page, no active filter).
|
|
@@ -491,7 +847,16 @@ export class DataTable {
|
|
|
491
847
|
tableWrap.className = this.config.readonly
|
|
492
848
|
? 'twm-preview-table-wrap twm-preview-table-wrap--wizard'
|
|
493
849
|
: 'twm-preview-table-wrap';
|
|
494
|
-
tableWrap.style.cssText =
|
|
850
|
+
tableWrap.style.cssText = fit
|
|
851
|
+
// As tall as its content, and the one thing that gives way under a
|
|
852
|
+
// `maxHeight`: the header and the pager keep their height.
|
|
853
|
+
? 'flex:0 1 auto; min-height:0; min-width:0; overflow:auto;'
|
|
854
|
+
: 'flex:1 1 0; min-height:0; min-width:0; overflow:auto;';
|
|
855
|
+
|
|
856
|
+
// 0.5.0 `setError` with rows on screen: the rows STAY, under a banner.
|
|
857
|
+
if (this._error && rows.length > 0) {
|
|
858
|
+
this._wrapperEl.appendChild(this._createErrorBanner());
|
|
859
|
+
}
|
|
495
860
|
|
|
496
861
|
if (rows.length === 0) {
|
|
497
862
|
// Empty state — render the same chrome as the data table
|
|
@@ -505,9 +870,7 @@ export class DataTable {
|
|
|
505
870
|
headerWrap.style.cssText
|
|
506
871
|
= 'flex:0 0 auto; overflow:hidden; min-width:0;';
|
|
507
872
|
const headerTable = document.createElement('table');
|
|
508
|
-
headerTable.className = this.
|
|
509
|
-
? 'twm-preview-table twm-preview-table--readonly'
|
|
510
|
-
: 'twm-preview-table';
|
|
873
|
+
headerTable.className = this._tableClassName();
|
|
511
874
|
const thead = document.createElement('thead');
|
|
512
875
|
const tr = document.createElement('tr');
|
|
513
876
|
if (showRowNumbers) {
|
|
@@ -540,9 +903,23 @@ export class DataTable {
|
|
|
540
903
|
const colCount = headers.length + (showRowNumbers ? 1 : 0);
|
|
541
904
|
const emptyTd = document.createElement('td');
|
|
542
905
|
emptyTd.colSpan = colCount;
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
906
|
+
if (this._error) {
|
|
907
|
+
// 0.5.0. Nothing to keep: the failure takes the empty row.
|
|
908
|
+
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--error';
|
|
909
|
+
emptyTd.setAttribute('role', 'alert');
|
|
910
|
+
this._appendStateContent(emptyTd, this._errorContent());
|
|
911
|
+
} else if (this.config.emptyState !== undefined) {
|
|
912
|
+
// 0.5.0. The consumer's own words (or node), styled by class
|
|
913
|
+
// rather than inline, and quiet while the table is loading.
|
|
914
|
+
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
|
|
915
|
+
const state = typeof this.config.emptyState === 'function'
|
|
916
|
+
? this.config.emptyState() : this.config.emptyState;
|
|
917
|
+
this._appendStateContent(emptyTd, state);
|
|
918
|
+
} else {
|
|
919
|
+
emptyTd.style.cssText
|
|
920
|
+
= 'text-align:center; font-style:italic; color:#888; padding:16px;';
|
|
921
|
+
emptyTd.textContent = emptyMessage;
|
|
922
|
+
}
|
|
546
923
|
emptyTr.appendChild(emptyTd);
|
|
547
924
|
tbody.appendChild(emptyTr);
|
|
548
925
|
bodyTable.appendChild(tbody);
|
|
@@ -579,6 +956,9 @@ export class DataTable {
|
|
|
579
956
|
|
|
580
957
|
this._tableWrapEl = tableWrap;
|
|
581
958
|
this.container.appendChild(this._wrapperEl);
|
|
959
|
+
// 0.5.0. Watched from here, so a table rendered again after an automatic
|
|
960
|
+
// dispose (a `setData` into a re-attached host) is watched again.
|
|
961
|
+
if (this.config.autoDispose) autoDisposeWatch(this);
|
|
582
962
|
if (prevScroll && this._state.offset === this._renderedOffset) {
|
|
583
963
|
tableWrap.scrollTop = prevScroll.top;
|
|
584
964
|
tableWrap.scrollLeft = prevScroll.left;
|
|
@@ -628,7 +1008,8 @@ export class DataTable {
|
|
|
628
1008
|
}
|
|
629
1009
|
|
|
630
1010
|
// Install interactions (readonly only prevents editing, not selection/copy)
|
|
631
|
-
if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
|
|
1011
|
+
if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
|
|
1012
|
+
|| typeof this.config.onRowActivate === 'function')) {
|
|
632
1013
|
this._installInteractions();
|
|
633
1014
|
}
|
|
634
1015
|
|
|
@@ -746,9 +1127,28 @@ export class DataTable {
|
|
|
746
1127
|
}
|
|
747
1128
|
|
|
748
1129
|
/**
|
|
749
|
-
* Dispose and cleanup
|
|
1130
|
+
* Dispose and cleanup. Empties the host, as it always has.
|
|
750
1131
|
*/
|
|
751
1132
|
dispose() {
|
|
1133
|
+
this._teardown(true);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/**
|
|
1137
|
+
* 0.5.0 `autoDispose`'s dispose: everything `dispose()` lets go of — but of
|
|
1138
|
+
* the host, only the table's OWN wrapper. A table that has been taken out
|
|
1139
|
+
* of the page no longer owns its host: in the task before this runs, the
|
|
1140
|
+
* consumer may have mounted the next list into it, or written its own
|
|
1141
|
+
* message there, and emptying the host would wipe that.
|
|
1142
|
+
*/
|
|
1143
|
+
_disposeOwn() {
|
|
1144
|
+
this._teardown(false);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
_teardown(emptyHost) {
|
|
1148
|
+
autoDisposeUnwatch(this);
|
|
1149
|
+
this._autoSeen = false;
|
|
1150
|
+
this._runOffs(this._activationOffs);
|
|
1151
|
+
this._activationOffs = [];
|
|
752
1152
|
this._cleanup();
|
|
753
1153
|
this._closeFilterDropdown();
|
|
754
1154
|
this._teardownContextMenu();
|
|
@@ -763,7 +1163,8 @@ export class DataTable {
|
|
|
763
1163
|
this._onBodyScroll = null;
|
|
764
1164
|
this._scrollSyncEl = null;
|
|
765
1165
|
}
|
|
766
|
-
this.container.innerHTML = '';
|
|
1166
|
+
if (emptyHost) this.container.innerHTML = '';
|
|
1167
|
+
else this._wrapperEl?.remove();
|
|
767
1168
|
this._wrapperEl = null;
|
|
768
1169
|
this._paginationEl = null;
|
|
769
1170
|
this._tableEl = null;
|
|
@@ -819,10 +1220,23 @@ export class DataTable {
|
|
|
819
1220
|
for (const [colIdx, filterText] of filters) {
|
|
820
1221
|
if (!filterText) continue;
|
|
821
1222
|
const value = row[colIdx];
|
|
822
|
-
const colType = this.
|
|
1223
|
+
const colType = this._valueKind(colIdx);
|
|
823
1224
|
|
|
824
1225
|
if (colType === 'num') {
|
|
825
1226
|
if (!this._matchNumericFilter(value, filterText)) { pass = false; break; }
|
|
1227
|
+
} else if (colType === 'date' || colType === 'datetime') {
|
|
1228
|
+
// 0.5.0. By the TIME, with the drawn words as the fallback
|
|
1229
|
+
// for text that names no period.
|
|
1230
|
+
const utc = this._utc();
|
|
1231
|
+
if (!matchDateFilter(parseDateValue(value, { utc }), filterText,
|
|
1232
|
+
this._formatValue(value, colIdx), { utc })) {
|
|
1233
|
+
pass = false; break;
|
|
1234
|
+
}
|
|
1235
|
+
} else if (colType === 'duration') {
|
|
1236
|
+
if (!matchDurationFilter(parseDuration(value), filterText,
|
|
1237
|
+
this._formatValue(value, colIdx))) {
|
|
1238
|
+
pass = false; break;
|
|
1239
|
+
}
|
|
826
1240
|
} else {
|
|
827
1241
|
if (!this._matchTextFilter(value, filterText)) { pass = false; break; }
|
|
828
1242
|
}
|
|
@@ -841,6 +1255,37 @@ export class DataTable {
|
|
|
841
1255
|
const colIdx = this._state.sortColumn;
|
|
842
1256
|
const asc = this._state.sortAscending;
|
|
843
1257
|
|
|
1258
|
+
// 0.5.0. A `sortValue` hook, or a column typed by its VALUE, sorts by a
|
|
1259
|
+
// key computed ONCE per row (a date is parsed once, not once per
|
|
1260
|
+
// comparison). Every other column takes the 0.4 comparator below,
|
|
1261
|
+
// untouched.
|
|
1262
|
+
const hook = this._sortValueFor(colIdx);
|
|
1263
|
+
const kind = this._valueKind(colIdx);
|
|
1264
|
+
if (hook || TYPED_COLUMNS.has(kind)) {
|
|
1265
|
+
const utc = this._utc();
|
|
1266
|
+
const keyOf = (row) => {
|
|
1267
|
+
const value = row[colIdx];
|
|
1268
|
+
if (hook) {
|
|
1269
|
+
const k = hook(value, colIdx, row);
|
|
1270
|
+
if (k !== undefined) return normaliseSortKey(k);
|
|
1271
|
+
}
|
|
1272
|
+
if (kind === 'date' || kind === 'datetime') {
|
|
1273
|
+
return normaliseSortKey(parseDateValue(value, { utc }));
|
|
1274
|
+
}
|
|
1275
|
+
if (kind === 'duration') return normaliseSortKey(parseDuration(value));
|
|
1276
|
+
if (kind === 'num') {
|
|
1277
|
+
return normaliseSortKey(typeof value === 'number' ? value : parseFloat(value));
|
|
1278
|
+
}
|
|
1279
|
+
return value == null ? null : String(value).toLowerCase();
|
|
1280
|
+
};
|
|
1281
|
+
const keyed = rows.map((row, i) => ({ row, origIdx: indexMap[i], key: keyOf(row) }));
|
|
1282
|
+
keyed.sort((a, b) => compareSortKeys(a.key, b.key, asc));
|
|
1283
|
+
return {
|
|
1284
|
+
rows: keyed.map((p) => p.row),
|
|
1285
|
+
indexMap: keyed.map((p) => p.origIdx),
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
844
1289
|
// Build paired array for stable sort with index tracking
|
|
845
1290
|
const paired = rows.map((row, i) => ({ row, origIdx: indexMap[i] }));
|
|
846
1291
|
const isNumeric = this._columnTypes[colIdx] === 'num';
|
|
@@ -971,8 +1416,11 @@ export class DataTable {
|
|
|
971
1416
|
const input = document.createElement('input');
|
|
972
1417
|
input.type = 'text';
|
|
973
1418
|
input.className = 'twm-data-table__filter-input';
|
|
974
|
-
const
|
|
975
|
-
input.placeholder =
|
|
1419
|
+
const kind = this._valueKind(colIdx);
|
|
1420
|
+
input.placeholder = kind === 'num' ? 'e.g. >100'
|
|
1421
|
+
: kind === 'date' || kind === 'datetime' ? 'e.g. >2026-01-01'
|
|
1422
|
+
: kind === 'duration' ? 'e.g. >1s'
|
|
1423
|
+
: 'Filter...';
|
|
976
1424
|
|
|
977
1425
|
// Restore existing filter value
|
|
978
1426
|
const existingFilter = this._state.filters.get(colIdx);
|
|
@@ -1030,9 +1478,14 @@ export class DataTable {
|
|
|
1030
1478
|
// Close any existing dropdown
|
|
1031
1479
|
this._closeFilterDropdown();
|
|
1032
1480
|
|
|
1033
|
-
const
|
|
1481
|
+
const kind = this._valueKind(colIdx);
|
|
1482
|
+
const isNumeric = kind === 'num';
|
|
1483
|
+
// 0.5.0. A date or a duration takes the numeric OPERATORS — before,
|
|
1484
|
+
// after, between — over operands typed as text (`2026-10`, `1.5s`).
|
|
1485
|
+
const isDate = kind === 'date' || kind === 'datetime';
|
|
1486
|
+
const isOrdered = isNumeric || isDate || kind === 'duration';
|
|
1034
1487
|
const currentFilter = this._state.filters.get(colIdx) || '';
|
|
1035
|
-
const parsed = this._parseFilterForDropdown(currentFilter, isNumeric);
|
|
1488
|
+
const parsed = this._parseFilterForDropdown(currentFilter, isOrdered ? (isNumeric ? true : kind) : false);
|
|
1036
1489
|
|
|
1037
1490
|
// Build dropdown panel
|
|
1038
1491
|
const panel = document.createElement('div');
|
|
@@ -1041,12 +1494,22 @@ export class DataTable {
|
|
|
1041
1494
|
// Operator/mode select
|
|
1042
1495
|
const selectLabel = document.createElement('label');
|
|
1043
1496
|
selectLabel.className = 'twm-data-table__filter-dropdown-label';
|
|
1044
|
-
selectLabel.textContent =
|
|
1497
|
+
selectLabel.textContent = isOrdered ? 'Operator' : 'Mode';
|
|
1045
1498
|
|
|
1046
1499
|
const select = document.createElement('select');
|
|
1047
1500
|
select.className = 'twm-data-table__filter-dropdown-select';
|
|
1048
1501
|
|
|
1049
|
-
const options =
|
|
1502
|
+
const options = isDate
|
|
1503
|
+
? [
|
|
1504
|
+
{ value: '=', label: 'On / in' },
|
|
1505
|
+
{ value: '!=', label: 'Not on / in' },
|
|
1506
|
+
{ value: '>', label: 'After' },
|
|
1507
|
+
{ value: '>=', label: 'On or after' },
|
|
1508
|
+
{ value: '<', label: 'Before' },
|
|
1509
|
+
{ value: '<=', label: 'On or before' },
|
|
1510
|
+
{ value: '..', label: 'Between' },
|
|
1511
|
+
]
|
|
1512
|
+
: isOrdered
|
|
1050
1513
|
? [
|
|
1051
1514
|
{ value: '=', label: 'Equals' },
|
|
1052
1515
|
{ value: '!=', label: 'Not equals' },
|
|
@@ -1077,10 +1540,12 @@ export class DataTable {
|
|
|
1077
1540
|
valueLabel.className = 'twm-data-table__filter-dropdown-label';
|
|
1078
1541
|
valueLabel.textContent = 'Value';
|
|
1079
1542
|
|
|
1543
|
+
const operandPlaceholder = isNumeric ? 'Number...'
|
|
1544
|
+
: isDate ? 'YYYY-MM-DD' : isOrdered ? 'e.g. 1.5s' : 'Text...';
|
|
1080
1545
|
const valueInput = document.createElement('input');
|
|
1081
1546
|
valueInput.type = isNumeric ? 'number' : 'text';
|
|
1082
1547
|
valueInput.className = 'twm-data-table__filter-dropdown-input';
|
|
1083
|
-
valueInput.placeholder =
|
|
1548
|
+
valueInput.placeholder = operandPlaceholder;
|
|
1084
1549
|
valueInput.value = parsed.value;
|
|
1085
1550
|
|
|
1086
1551
|
// Second value input (for "between")
|
|
@@ -1089,21 +1554,24 @@ export class DataTable {
|
|
|
1089
1554
|
value2Label.textContent = 'And';
|
|
1090
1555
|
|
|
1091
1556
|
const value2Input = document.createElement('input');
|
|
1092
|
-
value2Input.type = 'number';
|
|
1557
|
+
value2Input.type = isOrdered && !isNumeric ? 'text' : 'number';
|
|
1093
1558
|
value2Input.className = 'twm-data-table__filter-dropdown-input';
|
|
1094
|
-
value2Input.placeholder = 'Number...';
|
|
1559
|
+
value2Input.placeholder = isOrdered && !isNumeric ? operandPlaceholder : 'Number...';
|
|
1095
1560
|
value2Input.value = parsed.value2;
|
|
1096
1561
|
|
|
1097
1562
|
const value2Container = document.createElement('div');
|
|
1098
1563
|
value2Container.className = 'twm-data-table__filter-dropdown-between';
|
|
1099
|
-
value2Container.style.display = (
|
|
1564
|
+
value2Container.style.display = (isOrdered && parsed.operator === '..') ? '' : 'none';
|
|
1100
1565
|
value2Container.appendChild(value2Label);
|
|
1101
1566
|
value2Container.appendChild(value2Input);
|
|
1102
1567
|
|
|
1103
1568
|
// Toggle between fields when operator changes
|
|
1104
1569
|
select.addEventListener('change', () => {
|
|
1105
|
-
value2Container.style.display = (
|
|
1570
|
+
value2Container.style.display = (isOrdered && select.value === '..') ? '' : 'none';
|
|
1106
1571
|
});
|
|
1572
|
+
// What `_composeFilterString` is told: true for a number (0.4), the
|
|
1573
|
+
// kind for a date or a duration, false for text.
|
|
1574
|
+
const composeMode = isNumeric ? true : isOrdered ? kind : false;
|
|
1107
1575
|
|
|
1108
1576
|
// Actions
|
|
1109
1577
|
const actions = document.createElement('div');
|
|
@@ -1126,7 +1594,7 @@ export class DataTable {
|
|
|
1126
1594
|
applyBtn.textContent = 'Apply';
|
|
1127
1595
|
applyBtn.addEventListener('click', (e) => {
|
|
1128
1596
|
e.stopPropagation();
|
|
1129
|
-
const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value,
|
|
1597
|
+
const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, composeMode);
|
|
1130
1598
|
filterInput.value = composed;
|
|
1131
1599
|
this._onFilterInput(colIdx, composed);
|
|
1132
1600
|
this._closeFilterDropdown();
|
|
@@ -1180,7 +1648,7 @@ export class DataTable {
|
|
|
1180
1648
|
const handleEnter = (e) => {
|
|
1181
1649
|
if (e.key === 'Enter') {
|
|
1182
1650
|
e.preventDefault();
|
|
1183
|
-
const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value,
|
|
1651
|
+
const composed = this._composeFilterString(select.value, valueInput.value, value2Input.value, composeMode);
|
|
1184
1652
|
filterInput.value = composed;
|
|
1185
1653
|
this._onFilterInput(colIdx, composed);
|
|
1186
1654
|
this._closeFilterDropdown();
|
|
@@ -1216,6 +1684,16 @@ export class DataTable {
|
|
|
1216
1684
|
return { operator: isNumeric ? '=' : 'contains', value: '', value2: '' };
|
|
1217
1685
|
}
|
|
1218
1686
|
|
|
1687
|
+
// 0.5.0. A date or a duration (`isNumeric` is then the kind): the
|
|
1688
|
+
// numeric operators, over operands that are text.
|
|
1689
|
+
if (typeof isNumeric === 'string') {
|
|
1690
|
+
const range = text.match(/^(.+?)\s*\.\.\s*(.+)$/);
|
|
1691
|
+
if (range) return { operator: '..', value: range[1], value2: range[2] };
|
|
1692
|
+
const op = text.match(/^(>=|<=|!=|>|<|=)\s*(.+)$/);
|
|
1693
|
+
if (op) return { operator: op[1], value: op[2], value2: '' };
|
|
1694
|
+
return { operator: '=', value: text, value2: '' };
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1219
1697
|
if (isNumeric) {
|
|
1220
1698
|
// Range: "10..100"
|
|
1221
1699
|
const rangeMatch = text.match(/^(-?[\d.]+)\.\.(-?[\d.]+)$/);
|
|
@@ -1250,6 +1728,13 @@ export class DataTable {
|
|
|
1250
1728
|
_composeFilterString(operator, value, value2, isNumeric) {
|
|
1251
1729
|
if (!value && operator !== '..') return '';
|
|
1252
1730
|
|
|
1731
|
+
// 0.5.0. A date or a duration keeps its `=`: an operand with no
|
|
1732
|
+
// operator is matched against the drawn words, not compared.
|
|
1733
|
+
if (typeof isNumeric === 'string') {
|
|
1734
|
+
if (operator === '..') return (value && value2) ? `${value}..${value2}` : '';
|
|
1735
|
+
return `${operator}${value}`;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1253
1738
|
if (isNumeric) {
|
|
1254
1739
|
if (operator === '..') {
|
|
1255
1740
|
return (value && value2) ? `${value}..${value2}` : '';
|
|
@@ -1322,6 +1807,29 @@ export class DataTable {
|
|
|
1322
1807
|
// Private methods
|
|
1323
1808
|
// ─────────────────────────────────────────────────────────────────
|
|
1324
1809
|
|
|
1810
|
+
/** Run (and forget) a list of removers. */
|
|
1811
|
+
_runOffs(offs) {
|
|
1812
|
+
for (const off of offs || []) {
|
|
1813
|
+
try { off?.(); } catch (_) { /* already gone */ }
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
/**
|
|
1818
|
+
* 0.5.0. A render takes `onRowActivate`'s listeners off the body it is
|
|
1819
|
+
* replacing ONE TASK LATER, not now. The render may be running INSIDE the
|
|
1820
|
+
* very click that is about to open a row — an `onSelectionChange` that calls
|
|
1821
|
+
* `setData`, an `onRowClick` that calls `render` — and a listener removed
|
|
1822
|
+
* during its own event's dispatch is skipped: the row silently did not
|
|
1823
|
+
* open. The event's path was fixed when it was dispatched, so it still
|
|
1824
|
+
* reaches the old body, whose listener opens the row that was pressed. The
|
|
1825
|
+
* old body is out of the page, so nothing else can reach it meanwhile.
|
|
1826
|
+
*/
|
|
1827
|
+
_retireActivation() {
|
|
1828
|
+
const offs = this._activationOffs;
|
|
1829
|
+
this._activationOffs = [];
|
|
1830
|
+
if (offs.length) setTimeout(() => this._runOffs(offs), 0);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1325
1833
|
_cleanup() {
|
|
1326
1834
|
this._disposers.forEach(dispose => {
|
|
1327
1835
|
try { dispose?.(); } catch (_) {}
|
|
@@ -1335,10 +1843,18 @@ export class DataTable {
|
|
|
1335
1843
|
}
|
|
1336
1844
|
|
|
1337
1845
|
_detectColumnTypes() {
|
|
1338
|
-
const { headers, rows, getColumnType } = this.config;
|
|
1846
|
+
const { headers, rows, getColumnType, columnTypes } = this.config;
|
|
1339
1847
|
const types = [];
|
|
1340
1848
|
|
|
1341
1849
|
for (let colIdx = 0; colIdx < headers.length; colIdx++) {
|
|
1850
|
+
// 0.5.0. A type declared by position wins over the callback and
|
|
1851
|
+
// over detection — which never guesses a date: typing one is a
|
|
1852
|
+
// decision about how it is drawn.
|
|
1853
|
+
const declared = Array.isArray(columnTypes) ? columnTypes[colIdx] : null;
|
|
1854
|
+
if (declared) {
|
|
1855
|
+
types.push(declared);
|
|
1856
|
+
continue;
|
|
1857
|
+
}
|
|
1342
1858
|
if (getColumnType) {
|
|
1343
1859
|
types.push(getColumnType(colIdx, rows));
|
|
1344
1860
|
continue;
|
|
@@ -1534,7 +2050,7 @@ export class DataTable {
|
|
|
1534
2050
|
? processedRows : processedRows.slice(offset, offset + pageSize);
|
|
1535
2051
|
|
|
1536
2052
|
const table = document.createElement('table');
|
|
1537
|
-
table.className =
|
|
2053
|
+
table.className = this._tableClassName();
|
|
1538
2054
|
table.tabIndex = 0;
|
|
1539
2055
|
|
|
1540
2056
|
// Header
|
|
@@ -1624,6 +2140,11 @@ export class DataTable {
|
|
|
1624
2140
|
: (this._processedIndexMap?.[processedIdx] ?? processedIdx);
|
|
1625
2141
|
};
|
|
1626
2142
|
|
|
2143
|
+
// 0.5.0 `showRowNumbers: 'position'`: where the first row drawn here
|
|
2144
|
+
// stands in the order SHOWN (the page's first row on a later page).
|
|
2145
|
+
this._positionBase = isServerSide ? (configOffset || 0)
|
|
2146
|
+
: (this.config.pagination ? offset : 0);
|
|
2147
|
+
|
|
1627
2148
|
// Body
|
|
1628
2149
|
const tbody = document.createElement('tbody');
|
|
1629
2150
|
table.appendChild(tbody);
|
|
@@ -1649,11 +2170,15 @@ export class DataTable {
|
|
|
1649
2170
|
const { showRowNumbers } = this.config;
|
|
1650
2171
|
const tr = document.createElement('tr');
|
|
1651
2172
|
tr.__rowIndex = globalIdx;
|
|
2173
|
+
// 0.5.0. The row's place in the order shown (for 'position' numbers).
|
|
2174
|
+
tr.__position = (this._positionBase || 0) + localIdx;
|
|
1652
2175
|
tr.className = 'data-preview-row';
|
|
1653
2176
|
|
|
1654
2177
|
if (this._state.selected.has(globalIdx)) {
|
|
1655
2178
|
tr.classList.add('selected');
|
|
1656
2179
|
}
|
|
2180
|
+
// 0.5.0. The consumer's row classes and attributes, and the open mark.
|
|
2181
|
+
this._decorateRow(tr, row, globalIdx);
|
|
1657
2182
|
// C10. The stripe is `tr:nth-child(even)`, which is a question about a
|
|
1658
2183
|
// row's POSITION among its siblings — and a window that drops rows off
|
|
1659
2184
|
// its top changes every answer. `_virtSpacers` keeps the position's
|
|
@@ -2094,24 +2619,43 @@ export class DataTable {
|
|
|
2094
2619
|
if (showRowNumbers) {
|
|
2095
2620
|
const td = document.createElement('td');
|
|
2096
2621
|
td.className = 'num';
|
|
2097
|
-
|
|
2622
|
+
// 0.5.0. 'position' counts the rows as SHOWN; `true` keeps the 0.4
|
|
2623
|
+
// number, the row's original index.
|
|
2624
|
+
const byPosition = this.config.showRowNumbers === 'position'
|
|
2625
|
+
&& Number.isInteger(tr.__position);
|
|
2626
|
+
td.textContent = String((byPosition ? tr.__position : globalIdx) + 1);
|
|
2098
2627
|
tr.appendChild(td);
|
|
2099
2628
|
}
|
|
2100
2629
|
|
|
2630
|
+
const { cellClass, nullDisplay } = this.config;
|
|
2101
2631
|
for (let colIdx = 0; colIdx < row.length; colIdx++) {
|
|
2102
2632
|
const td = document.createElement('td');
|
|
2103
2633
|
td.className = this._columnTypes[colIdx] || 'text';
|
|
2104
2634
|
|
|
2105
2635
|
// Use custom cell renderer if provided
|
|
2106
2636
|
const value = row[colIdx];
|
|
2637
|
+
let handled = false;
|
|
2107
2638
|
if (this.config.renderCell) {
|
|
2108
|
-
|
|
2639
|
+
handled = !!this.config.renderCell(td, value, colIdx, globalIdx, row);
|
|
2109
2640
|
if (!handled) {
|
|
2110
2641
|
td.textContent = this._formatValue(value, colIdx);
|
|
2111
2642
|
}
|
|
2112
2643
|
} else {
|
|
2113
2644
|
td.textContent = this._formatValue(value, colIdx);
|
|
2114
2645
|
}
|
|
2646
|
+
// 0.5.0 `nullDisplay`: a NULL is marked as one, so it can be drawn
|
|
2647
|
+
// unlike the text "NULL" — unless `renderCell` drew the cell.
|
|
2648
|
+
if (!handled && value == null && nullDisplay != null) {
|
|
2649
|
+
td.classList.add('twm-dt-cell--null');
|
|
2650
|
+
}
|
|
2651
|
+
// 0.5.0 `cellClass`.
|
|
2652
|
+
if (typeof cellClass === 'function') {
|
|
2653
|
+
this._addClasses(td, cellClass(value, colIdx, row, globalIdx));
|
|
2654
|
+
}
|
|
2655
|
+
// 0.5.0 `rowIcon`, at the start of the first data cell.
|
|
2656
|
+
if (colIdx === 0 && typeof this.config.rowIcon === 'function') {
|
|
2657
|
+
this._prependRowIcon(td, this.config.rowIcon(row, globalIdx));
|
|
2658
|
+
}
|
|
2115
2659
|
|
|
2116
2660
|
// Cell-level right-click hook (P2). Fires before the row-level
|
|
2117
2661
|
// hook and the built-in context menu — the caller can
|
|
@@ -2228,6 +2772,9 @@ export class DataTable {
|
|
|
2228
2772
|
}
|
|
2229
2773
|
|
|
2230
2774
|
tr.classList.toggle('selected', this._state?.selected?.has(index) === true);
|
|
2775
|
+
// 0.5.0. The row's data changed, so its classes, attributes, key and
|
|
2776
|
+
// open mark are asked again (a key paused, a delivery that died).
|
|
2777
|
+
this._decorateRow(tr, row, index);
|
|
2231
2778
|
|
|
2232
2779
|
if (focusedCol >= 0 && tr.children[focusedCol]) {
|
|
2233
2780
|
tr.children[focusedCol].focus?.();
|
|
@@ -2235,6 +2782,334 @@ export class DataTable {
|
|
|
2235
2782
|
return true;
|
|
2236
2783
|
}
|
|
2237
2784
|
|
|
2785
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2786
|
+
// 0.5.0 — decoration, states and the typed columns
|
|
2787
|
+
// ─────────────────────────────────────────────────────────────────
|
|
2788
|
+
|
|
2789
|
+
/** The class list both halves of the table carry: the 0.4 pair, plus the
|
|
2790
|
+
* 0.5.0 modifiers a consumer asked for. */
|
|
2791
|
+
_tableClassName() {
|
|
2792
|
+
const names = this.config.readonly
|
|
2793
|
+
? ['twm-preview-table', 'twm-preview-table--readonly']
|
|
2794
|
+
: ['twm-preview-table'];
|
|
2795
|
+
if (this.config.firstColumn === 'plain') names.push('twm-dt--first-plain');
|
|
2796
|
+
if (this._isClickable()) names.push('twm-dt--clickable');
|
|
2797
|
+
return names.join(' ');
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
/** `clickable` when it is said; otherwise exactly when rows open. */
|
|
2801
|
+
_isClickable() {
|
|
2802
|
+
const c = this.config.clickable;
|
|
2803
|
+
if (c === true || c === false) return c;
|
|
2804
|
+
return typeof this.config.onRowActivate === 'function';
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
_fitsContent() {
|
|
2808
|
+
return !!this.config.fitContent || this._maxHeightCss() !== null;
|
|
2809
|
+
}
|
|
2810
|
+
|
|
2811
|
+
_maxHeightCss() {
|
|
2812
|
+
const m = this.config.maxHeight;
|
|
2813
|
+
if (m == null || m === '' || m === false) return null;
|
|
2814
|
+
if (typeof m === 'number') return Number.isFinite(m) && m > 0 ? `${m}px` : null;
|
|
2815
|
+
return String(m);
|
|
2816
|
+
}
|
|
2817
|
+
|
|
2818
|
+
_utc() {
|
|
2819
|
+
return String(this.config.dateTimeZone || '').toUpperCase() === 'UTC';
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
/**
|
|
2823
|
+
* What a column is drawn, sorted and filtered BY: its type, except that a
|
|
2824
|
+
* 'date', 'datetime' or 'duration' counts only when it was DECLARED through
|
|
2825
|
+
* `columnTypes`. In 0.4, whatever `getColumnType` returned was the cells'
|
|
2826
|
+
* class name and nothing more (only 'num' changed the sort and the
|
|
2827
|
+
* filter), so a consumer that returned 'date' there to style its cells gets
|
|
2828
|
+
* exactly 0.4 — its `formatValue`, its text sort, its text filter — and
|
|
2829
|
+
* the class name it asked for.
|
|
2830
|
+
*/
|
|
2831
|
+
_valueKind(colIdx) {
|
|
2832
|
+
const t = this._columnTypes?.[colIdx];
|
|
2833
|
+
if (!TYPED_COLUMNS.has(t)) return t;
|
|
2834
|
+
const declared = Array.isArray(this.config.columnTypes) ? this.config.columnTypes[colIdx] : null;
|
|
2835
|
+
return declared === t ? t : 'text';
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
/** `classList.add` for whatever a hook returned: a string (space-separated
|
|
2839
|
+
* names allowed), an array of them, or nothing. */
|
|
2840
|
+
_addClasses(el, names) {
|
|
2841
|
+
if (!names) return [];
|
|
2842
|
+
const list = (Array.isArray(names) ? names : String(names).split(/\s+/))
|
|
2843
|
+
.map((n) => String(n || '').trim()).filter(Boolean);
|
|
2844
|
+
if (list.length) el.classList.add(...list);
|
|
2845
|
+
return list;
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
/** A row's key, as text, or null: `getRowKey`, else its original index. */
|
|
2849
|
+
_rowKey(row, rowIdx) {
|
|
2850
|
+
const fn = this.config.getRowKey;
|
|
2851
|
+
let key;
|
|
2852
|
+
if (typeof fn === 'function') {
|
|
2853
|
+
try { key = fn(row, rowIdx); } catch (err) {
|
|
2854
|
+
console.error('[DataTable] getRowKey threw', err);
|
|
2855
|
+
key = null;
|
|
2856
|
+
}
|
|
2857
|
+
} else {
|
|
2858
|
+
key = rowIdx;
|
|
2859
|
+
}
|
|
2860
|
+
return key == null ? null : String(key);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
/** The gestures `activateOn` names, as a set. */
|
|
2864
|
+
_activateOn() {
|
|
2865
|
+
const a = this.config.activateOn;
|
|
2866
|
+
const list = Array.isArray(a) ? a : [a || 'click'];
|
|
2867
|
+
return new Set(list.map((g) => String(g).toLowerCase()));
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/**
|
|
2871
|
+
* Open a row on the gestures `activateOn` names, by the one rule in
|
|
2872
|
+
* `row_activation.js` (a control is its own gesture, a drag that selects
|
|
2873
|
+
* text is not a click, the second click of a double-click opens nothing).
|
|
2874
|
+
* Delegated on the body, bound after the selection handler.
|
|
2875
|
+
*
|
|
2876
|
+
* The listeners go in `_activationOffs`, not `_disposers`, and a render
|
|
2877
|
+
* retires them a task late (`_retireActivation`): a press whose selection
|
|
2878
|
+
* callback re-draws the table still opens the row it pressed — the row
|
|
2879
|
+
* as it was drawn (`tr.__row`), with its original index.
|
|
2880
|
+
*/
|
|
2881
|
+
_installActivation(tbody, table) {
|
|
2882
|
+
const on = this._activateOn();
|
|
2883
|
+
const scope = this._wrapperEl;
|
|
2884
|
+
// A table that selects several rows reads Shift and Ctrl as SELECTION
|
|
2885
|
+
// gestures; one that does not leaves them to the consumer (ev.ctrlKey:
|
|
2886
|
+
// "open it somewhere else").
|
|
2887
|
+
const sel = this.config.selectable;
|
|
2888
|
+
const multi = !!sel && sel !== 'single';
|
|
2889
|
+
const fire = (tr, ev) => {
|
|
2890
|
+
// A table disposed by the press (its selection callback threw the
|
|
2891
|
+
// list away) opens nothing.
|
|
2892
|
+
if (!this._wrapperEl || typeof this.config.onRowActivate !== 'function') return;
|
|
2893
|
+
try { this.config.onRowActivate(tr.__rowIndex, tr.__row, ev); } catch (err) {
|
|
2894
|
+
console.error('[DataTable] onRowActivate threw', err);
|
|
2895
|
+
}
|
|
2896
|
+
};
|
|
2897
|
+
const rowFor = (ev) => {
|
|
2898
|
+
const tr = ev.target?.closest?.('tr');
|
|
2899
|
+
return tr && tr.__rowIndex !== undefined && tbody.contains(tr) ? tr : null;
|
|
2900
|
+
};
|
|
2901
|
+
const listen = (el, type, fn) => {
|
|
2902
|
+
el.addEventListener(type, fn);
|
|
2903
|
+
this._activationOffs.push(() => el.removeEventListener(type, fn));
|
|
2904
|
+
};
|
|
2905
|
+
if (on.has('click')) {
|
|
2906
|
+
listen(tbody, 'click', (ev) => {
|
|
2907
|
+
if (ev.button != null && ev.button !== 0) return;
|
|
2908
|
+
const tr = rowFor(ev);
|
|
2909
|
+
if (!tr || !isRowActivation(ev, 'click', { scope })) return;
|
|
2910
|
+
if (multi && (ev.shiftKey || ev.ctrlKey || ev.metaKey)) return;
|
|
2911
|
+
// 'single': a Ctrl click that took the row back OFF the
|
|
2912
|
+
// selection does not also open it (the selection handler, bound
|
|
2913
|
+
// first, said so on the event).
|
|
2914
|
+
if (DESELECTING_CLICKS.has(ev)) return;
|
|
2915
|
+
fire(tr, ev);
|
|
2916
|
+
});
|
|
2917
|
+
}
|
|
2918
|
+
if (on.has('dblclick')) {
|
|
2919
|
+
listen(tbody, 'dblclick', (ev) => {
|
|
2920
|
+
const tr = rowFor(ev);
|
|
2921
|
+
if (!tr || !isRowActivation(ev, 'dblclick', { scope })) return;
|
|
2922
|
+
fire(tr, ev);
|
|
2923
|
+
});
|
|
2924
|
+
}
|
|
2925
|
+
if (on.has('enter')) {
|
|
2926
|
+
listen(table, 'keydown', (ev) => {
|
|
2927
|
+
if (ev.key !== 'Enter' || ev.isComposing) return;
|
|
2928
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) return;
|
|
2929
|
+
if (isRowControl(ev.target, table)) return;
|
|
2930
|
+
const tr = this._currentRowEl();
|
|
2931
|
+
if (!tr) return;
|
|
2932
|
+
ev.preventDefault();
|
|
2933
|
+
fire(tr, ev);
|
|
2934
|
+
});
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
|
|
2938
|
+
/**
|
|
2939
|
+
* The row Enter opens: the SELECTED one — the one row selected, or the
|
|
2940
|
+
* selection's anchor — because that is the row a person just chose (a
|
|
2941
|
+
* double-click opens the row it lands on, and Enter must agree with it);
|
|
2942
|
+
* the open row (`activeRow`) only when nothing is selected. A selected row
|
|
2943
|
+
* that is not drawn (another page) opens nothing rather than a different
|
|
2944
|
+
* row.
|
|
2945
|
+
*/
|
|
2946
|
+
_currentRowEl() {
|
|
2947
|
+
const tbody = this._tbodyEl;
|
|
2948
|
+
if (!tbody) return null;
|
|
2949
|
+
const selected = this._state.selected;
|
|
2950
|
+
let idx = null;
|
|
2951
|
+
if (selected.size === 1) idx = [...selected][0];
|
|
2952
|
+
else if (this._state.anchorIndex != null && selected.has(this._state.anchorIndex)) {
|
|
2953
|
+
idx = this._state.anchorIndex;
|
|
2954
|
+
}
|
|
2955
|
+
if (idx != null) return this.getRowElement(idx);
|
|
2956
|
+
if (this._activeKey !== null) {
|
|
2957
|
+
for (const tr of tbody.children) {
|
|
2958
|
+
if (tr.__rowIndex !== undefined && tr.classList.contains('twm-dt-row--active')) return tr;
|
|
2959
|
+
}
|
|
2960
|
+
}
|
|
2961
|
+
return null;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2964
|
+
/** Everything `rowClass`, `rowAttrs` and the open mark put on a `<tr>`,
|
|
2965
|
+
* taken off again first — so `updateRow` cannot leave last state's class
|
|
2966
|
+
* behind. */
|
|
2967
|
+
_decorateRow(tr, row, rowIdx) {
|
|
2968
|
+
// The row's data, for a delegated handler (`onRowActivate`).
|
|
2969
|
+
tr.__row = row;
|
|
2970
|
+
const { rowClass, rowAttrs } = this.config;
|
|
2971
|
+
if (tr.__twmClasses) {
|
|
2972
|
+
for (const c of tr.__twmClasses) tr.classList.remove(c);
|
|
2973
|
+
tr.__twmClasses = null;
|
|
2974
|
+
}
|
|
2975
|
+
if (typeof rowClass === 'function') {
|
|
2976
|
+
const added = this._addClasses(tr, rowClass(row, rowIdx));
|
|
2977
|
+
// Never take away a class this component owns.
|
|
2978
|
+
tr.__twmClasses = added.filter((c) => c !== 'data-preview-row' && c !== 'selected');
|
|
2979
|
+
}
|
|
2980
|
+
if (tr.__twmAttrs) {
|
|
2981
|
+
for (const name of tr.__twmAttrs) tr.removeAttribute(name);
|
|
2982
|
+
tr.__twmAttrs = null;
|
|
2983
|
+
}
|
|
2984
|
+
if (typeof rowAttrs === 'function') {
|
|
2985
|
+
const attrs = rowAttrs(row, rowIdx) || {};
|
|
2986
|
+
const set = [];
|
|
2987
|
+
for (const [name, value] of Object.entries(attrs)) {
|
|
2988
|
+
if (value == null || value === false) continue;
|
|
2989
|
+
try {
|
|
2990
|
+
tr.setAttribute(name, value === true ? '' : String(value));
|
|
2991
|
+
set.push(name);
|
|
2992
|
+
} catch (err) {
|
|
2993
|
+
console.error(`[DataTable] rowAttrs: bad attribute "${name}"`, err);
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
tr.__twmAttrs = set;
|
|
2997
|
+
}
|
|
2998
|
+
if (typeof this.config.getRowKey === 'function' || this._activeKey !== null) {
|
|
2999
|
+
tr.__rowKey = this._rowKey(row, rowIdx);
|
|
3000
|
+
}
|
|
3001
|
+
this._paintActive(tr);
|
|
3002
|
+
}
|
|
3003
|
+
|
|
3004
|
+
/** The open mark on one row, from `_activeKey`. */
|
|
3005
|
+
_paintActive(tr) {
|
|
3006
|
+
if (tr.__rowKey === undefined && this._activeKey !== null && tr.__rowIndex !== undefined) {
|
|
3007
|
+
// A row drawn before a key was asked for (setActiveRow after render).
|
|
3008
|
+
const rows = this.config.rows;
|
|
3009
|
+
const local = this._virt ? this._virtLocalOf(tr.__rowIndex) : -1;
|
|
3010
|
+
const row = local >= 0 ? this._virt.rows[local] : rows?.[tr.__rowIndex];
|
|
3011
|
+
tr.__rowKey = this._rowKey(row, tr.__rowIndex);
|
|
3012
|
+
}
|
|
3013
|
+
const on = this._activeKey !== null && tr.__rowKey === this._activeKey;
|
|
3014
|
+
tr.classList.toggle('twm-dt-row--active', on);
|
|
3015
|
+
// `aria-current` is taken off only where THIS component wrote it. A
|
|
3016
|
+
// consumer that marks its open row itself (0.4 had no mark of its own,
|
|
3017
|
+
// and `updateRow` re-decorates a row) keeps the attribute it set.
|
|
3018
|
+
if (on) {
|
|
3019
|
+
if (tr.getAttribute('aria-current') !== 'true') {
|
|
3020
|
+
tr.setAttribute('aria-current', 'true');
|
|
3021
|
+
tr.__twmAriaCurrent = true;
|
|
3022
|
+
}
|
|
3023
|
+
} else if (tr.__twmAriaCurrent) {
|
|
3024
|
+
tr.__twmAriaCurrent = false;
|
|
3025
|
+
if (!(tr.__twmAttrs || []).includes('aria-current')) tr.removeAttribute('aria-current');
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
/** The row icon, drawn by CSS from `data-twm-icon` so that its name is in
|
|
3030
|
+
* neither `textContent`, the clipped-cell tooltip nor a copy. */
|
|
3031
|
+
_prependRowIcon(td, spec) {
|
|
3032
|
+
if (!spec) return;
|
|
3033
|
+
const s = typeof spec === 'string' ? { icon: spec } : spec;
|
|
3034
|
+
if (!s.icon) return;
|
|
3035
|
+
const icon = document.createElement('span');
|
|
3036
|
+
icon.className = 'material-symbols-outlined twm-dt-row-icon'
|
|
3037
|
+
+ (s.tone ? ` twm-dt-row-icon--${String(s.tone).replace(/[^\w-]/g, '')}` : '');
|
|
3038
|
+
icon.dataset.twmIcon = String(s.icon);
|
|
3039
|
+
if (s.title) {
|
|
3040
|
+
icon.title = String(s.title);
|
|
3041
|
+
icon.setAttribute('role', 'img');
|
|
3042
|
+
icon.setAttribute('aria-label', String(s.title));
|
|
3043
|
+
} else {
|
|
3044
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
3045
|
+
}
|
|
3046
|
+
td.insertBefore(icon, td.firstChild);
|
|
3047
|
+
td.classList.add('twm-dt-cell--has-icon');
|
|
3048
|
+
}
|
|
3049
|
+
|
|
3050
|
+
/** Text, a node, or nothing, into an empty-state cell. */
|
|
3051
|
+
_appendStateContent(td, content) {
|
|
3052
|
+
if (content == null || content === false) return;
|
|
3053
|
+
if (typeof Node !== 'undefined' && content instanceof Node) td.appendChild(content);
|
|
3054
|
+
else td.textContent = String(content);
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
/** What `setError` holds, as something `_appendStateContent` can draw. */
|
|
3058
|
+
_errorContent() {
|
|
3059
|
+
const e = this._error;
|
|
3060
|
+
if (e == null) return null;
|
|
3061
|
+
if (typeof Node !== 'undefined' && e instanceof Node) return e;
|
|
3062
|
+
if (e instanceof Error) return e.message || String(e);
|
|
3063
|
+
if (typeof e === 'object' && e.message) return String(e.message);
|
|
3064
|
+
return String(e);
|
|
3065
|
+
}
|
|
3066
|
+
|
|
3067
|
+
/** The failure banner over rows that are kept. */
|
|
3068
|
+
_createErrorBanner() {
|
|
3069
|
+
const banner = document.createElement('div');
|
|
3070
|
+
banner.className = 'twm-data-table__error';
|
|
3071
|
+
banner.setAttribute('role', 'alert');
|
|
3072
|
+
const icon = document.createElement('span');
|
|
3073
|
+
icon.className = 'material-symbols-outlined twm-data-table__error-icon';
|
|
3074
|
+
icon.setAttribute('aria-hidden', 'true');
|
|
3075
|
+
icon.textContent = 'error';
|
|
3076
|
+
const text = document.createElement('span');
|
|
3077
|
+
text.className = 'twm-data-table__error-text';
|
|
3078
|
+
const content = this._errorContent();
|
|
3079
|
+
// A node is the consumer's (and is MOVED here); a copy would lose its
|
|
3080
|
+
// listeners.
|
|
3081
|
+
if (typeof Node !== 'undefined' && content instanceof Node) text.appendChild(content);
|
|
3082
|
+
else text.textContent = String(content ?? '');
|
|
3083
|
+
banner.append(icon, text);
|
|
3084
|
+
return banner;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
/** The `sortValue` hook for one column, or null. */
|
|
3088
|
+
_sortValueFor(colIdx) {
|
|
3089
|
+
const sv = this.config.sortValue;
|
|
3090
|
+
if (typeof sv === 'function') return sv;
|
|
3091
|
+
const fn = perColumn(sv, colIdx);
|
|
3092
|
+
return typeof fn === 'function' ? fn : null;
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
/** The words for a value in a typed column, or `undefined` when the
|
|
3096
|
+
* column is not typed (or the value is empty). */
|
|
3097
|
+
_formatTyped(value, colIdx) {
|
|
3098
|
+
const kind = this._valueKind(colIdx);
|
|
3099
|
+
if (!TYPED_COLUMNS.has(kind) || value == null || value === '') return undefined;
|
|
3100
|
+
if (kind === 'duration') {
|
|
3101
|
+
return formatDuration(value, perColumn(this.config.durationFormat, colIdx) || 'auto');
|
|
3102
|
+
}
|
|
3103
|
+
const pattern = kind === 'datetime'
|
|
3104
|
+
? perColumn(this.config.dateTimeFormat, colIdx) || ISO_DATETIME
|
|
3105
|
+
: perColumn(this.config.dateFormat, colIdx) || ISO_DATE;
|
|
3106
|
+
if (typeof pattern === 'function') {
|
|
3107
|
+
const t = parseDateValue(value, { utc: this._utc() });
|
|
3108
|
+
return Number.isFinite(t) ? String(pattern(new Date(t), colIdx, value) ?? '') : String(value);
|
|
3109
|
+
}
|
|
3110
|
+
return formatDate(value, pattern, { utc: this._utc() });
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2238
3113
|
/** Sync the (separate) header table's column widths to the body
|
|
2239
3114
|
* table's measured widths. Without this, the two tables compute
|
|
2240
3115
|
* widths independently and the columns drift apart. Locks both
|
|
@@ -3032,7 +3907,15 @@ export class DataTable {
|
|
|
3032
3907
|
const idx = rowEl.__rowIndex;
|
|
3033
3908
|
const selected = this._state.selected;
|
|
3034
3909
|
|
|
3035
|
-
if (
|
|
3910
|
+
if (selectable === 'single') {
|
|
3911
|
+
// 0.5.0. One row, whatever the modifiers say: Ctrl takes
|
|
3912
|
+
// the one row back off, nothing extends it.
|
|
3913
|
+
const was = selected.has(idx);
|
|
3914
|
+
selected.clear();
|
|
3915
|
+
if (!(was && (event.metaKey || event.ctrlKey))) selected.add(idx);
|
|
3916
|
+
else DESELECTING_CLICKS.add(event); // so it does not ALSO open it
|
|
3917
|
+
this._state.anchorIndex = selected.size ? idx : null;
|
|
3918
|
+
} else if (event.shiftKey && this._state.anchorIndex != null) {
|
|
3036
3919
|
const start = Math.min(this._state.anchorIndex, idx);
|
|
3037
3920
|
const end = Math.max(this._state.anchorIndex, idx);
|
|
3038
3921
|
selected.clear();
|
|
@@ -3056,6 +3939,14 @@ export class DataTable {
|
|
|
3056
3939
|
this._disposers.push(() => tbody.removeEventListener('click', handleRowClick));
|
|
3057
3940
|
}
|
|
3058
3941
|
|
|
3942
|
+
// 0.5.0 `onRowActivate`. Bound AFTER the selection handler on the same
|
|
3943
|
+
// element, so a row is selected before it is opened — the listener
|
|
3944
|
+
// order is the guarantee. `onRowClick` stays exactly where it was (on
|
|
3945
|
+
// the `<tr>`, before the selection), for whoever already relies on that.
|
|
3946
|
+
if (typeof this.config.onRowActivate === 'function') {
|
|
3947
|
+
this._installActivation(tbody, table);
|
|
3948
|
+
}
|
|
3949
|
+
|
|
3059
3950
|
if (copyable) {
|
|
3060
3951
|
const handleContextMenu = (event) => {
|
|
3061
3952
|
const rowEl = event.target.closest('tr');
|
|
@@ -3087,7 +3978,11 @@ export class DataTable {
|
|
|
3087
3978
|
const ctrlLike = event.ctrlKey || event.metaKey;
|
|
3088
3979
|
if (ctrlLike && !event.altKey) {
|
|
3089
3980
|
const key = String(event.key || '').toLowerCase();
|
|
3090
|
-
if (key === 'a' && selectable) {
|
|
3981
|
+
if (key === 'a' && selectable === 'single') {
|
|
3982
|
+
// 0.5.0. Nothing to select all of — and not the page's
|
|
3983
|
+
// text either.
|
|
3984
|
+
event.preventDefault();
|
|
3985
|
+
} else if (key === 'a' && selectable) {
|
|
3091
3986
|
event.preventDefault();
|
|
3092
3987
|
this._state.selected.clear();
|
|
3093
3988
|
// Select only visible (filtered) rows via index map
|
|
@@ -3170,8 +4065,24 @@ export class DataTable {
|
|
|
3170
4065
|
e.stopPropagation();
|
|
3171
4066
|
});
|
|
3172
4067
|
|
|
4068
|
+
this._contextMenuEl = menu;
|
|
4069
|
+
return menu;
|
|
4070
|
+
}
|
|
4071
|
+
|
|
4072
|
+
/**
|
|
4073
|
+
* The document-level listeners that close the copy menu — a click
|
|
4074
|
+
* elsewhere, a scroll, Escape, a resize — held ONLY WHILE IT IS OPEN
|
|
4075
|
+
* (0.5.0). They were installed the first time the menu was built and kept
|
|
4076
|
+
* until `dispose()`, so every table anybody had ever right-clicked kept four
|
|
4077
|
+
* listeners on the document for as long as the instance lived, and an
|
|
4078
|
+
* instance whose host was thrown away without a `dispose()` kept them for
|
|
4079
|
+
* the life of the page. A closed menu has nothing for them to do.
|
|
4080
|
+
*/
|
|
4081
|
+
_armContextMenuGlobals() {
|
|
4082
|
+
if (this._contextMenuGlobals.length) return;
|
|
4083
|
+
const menu = this._contextMenuEl;
|
|
3173
4084
|
const hideOnGlobal = (event) => {
|
|
3174
|
-
if (event?.target && menu
|
|
4085
|
+
if (event?.target && menu?.contains(event.target)) return;
|
|
3175
4086
|
this._hideContextMenu();
|
|
3176
4087
|
};
|
|
3177
4088
|
const hideOnEscape = (event) => {
|
|
@@ -3188,19 +4099,25 @@ export class DataTable {
|
|
|
3188
4099
|
this._contextMenuGlobals.push(() => document.removeEventListener('scroll', hideOnGlobal, true));
|
|
3189
4100
|
this._contextMenuGlobals.push(() => document.removeEventListener('keydown', hideOnEscape));
|
|
3190
4101
|
this._contextMenuGlobals.push(() => window.removeEventListener('resize', hideOnResize));
|
|
4102
|
+
}
|
|
3191
4103
|
|
|
3192
|
-
|
|
3193
|
-
|
|
4104
|
+
_disarmContextMenuGlobals() {
|
|
4105
|
+
this._contextMenuGlobals.forEach((off) => {
|
|
4106
|
+
try { off?.(); } catch (_) { /* already gone */ }
|
|
4107
|
+
});
|
|
4108
|
+
this._contextMenuGlobals = [];
|
|
3194
4109
|
}
|
|
3195
4110
|
|
|
3196
4111
|
_hideContextMenu() {
|
|
3197
4112
|
if (this._contextMenuEl) {
|
|
3198
4113
|
this._contextMenuEl.style.display = 'none';
|
|
3199
4114
|
}
|
|
4115
|
+
this._disarmContextMenuGlobals();
|
|
3200
4116
|
}
|
|
3201
4117
|
|
|
3202
4118
|
_showContextMenu(clientX, clientY) {
|
|
3203
4119
|
const menu = this._ensureContextMenu();
|
|
4120
|
+
this._armContextMenuGlobals();
|
|
3204
4121
|
const hasSelection = this._state.selected.size > 0;
|
|
3205
4122
|
|
|
3206
4123
|
menu.querySelectorAll('.twm-context-menu-item').forEach(item => {
|
|
@@ -3236,6 +4153,13 @@ export class DataTable {
|
|
|
3236
4153
|
}
|
|
3237
4154
|
|
|
3238
4155
|
_formatValue(value, colIndex) {
|
|
4156
|
+
// 0.5.0 `nullDisplay`, then a typed column's own words — each only when
|
|
4157
|
+
// asked for, so a table without them formats exactly as 0.4 did.
|
|
4158
|
+
if (value == null && this.config.nullDisplay != null) {
|
|
4159
|
+
return String(this.config.nullDisplay);
|
|
4160
|
+
}
|
|
4161
|
+
const typed = this._formatTyped(value, colIndex);
|
|
4162
|
+
if (typed !== undefined) return typed;
|
|
3239
4163
|
if (this.config.formatValue) {
|
|
3240
4164
|
return this.config.formatValue(value, colIndex);
|
|
3241
4165
|
}
|