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.
@@ -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
- this._wrapperEl.style.cssText = 'display:flex; flex-direction:column; height:100%; min-height:0;';
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 = 'flex:1 1 0; min-height:0; min-width:0; overflow:auto;';
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.config.readonly
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
- emptyTd.style.cssText
544
- = 'text-align:center; font-style:italic; color:#888; padding:16px;';
545
- emptyTd.textContent = emptyMessage;
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._columnTypes[colIdx];
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 isNumeric = this._columnTypes[colIdx] === 'num';
975
- input.placeholder = isNumeric ? 'e.g. >100' : 'Filter...';
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 isNumeric = this._columnTypes[colIdx] === 'num';
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 = isNumeric ? 'Operator' : 'Mode';
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 = isNumeric
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 = isNumeric ? 'Number...' : 'Text...';
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 = (isNumeric && parsed.operator === '..') ? '' : 'none';
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 = (isNumeric && select.value === '..') ? '' : 'none';
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, isNumeric);
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, isNumeric);
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 = readonly ? 'twm-preview-table twm-preview-table--readonly' : 'twm-preview-table';
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
- td.textContent = String(globalIdx + 1);
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
- const handled = this.config.renderCell(td, value, colIdx, globalIdx, row);
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 (event.shiftKey && this._state.anchorIndex != null) {
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.contains(event.target)) return;
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
- this._contextMenuEl = menu;
3193
- return menu;
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
  }