flexdesk 0.6.3 → 0.7.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.
@@ -24,6 +24,11 @@ import {
24
24
  matchDurationFilter, parseDateValue, parseDuration,
25
25
  } from './table_values.js';
26
26
  import { isRowActivation, isRowControl } from './row_activation.js';
27
+ import { createChipsFilter } from './table_filter_chips.js';
28
+
29
+ // 0.7.0: the built-in chips filter control, for a consumer that wants to build
30
+ // one outside a table (the same control `filterKinds: {kind: 'chips'}` draws).
31
+ export { createChipsFilter } from './table_filter_chips.js';
27
32
 
28
33
  // 0.5.0: the value helpers a typed column uses, and the one row-activation rule,
29
34
  // re-exported so a consumer reaches them where it reaches DataTable.
@@ -134,23 +139,41 @@ function autoDisposeSweep(entry) {
134
139
  if (!table) { entry.tables.delete(ref); continue; }
135
140
  const el = table._wrapperEl;
136
141
  if (!el) continue;
137
- if (el.isConnected) { table._autoSeen = true; continue; }
142
+ if (el.isConnected) {
143
+ table._autoSeen = true;
144
+ // 0.7.0. BACK IN: whatever grace was running for the last time it
145
+ // went out is over. Without this, a table taken out, put back and
146
+ // taken out again was judged by the FIRST departure's clock, and
147
+ // disposed while it had been out for less than its grace.
148
+ if (table._autoPending) {
149
+ clearTimeout(table._autoTimer);
150
+ table._autoTimer = null;
151
+ table._autoPending = false;
152
+ }
153
+ continue;
154
+ }
138
155
  if (!table._autoSeen || table._autoPending) continue;
156
+ // 0.7.0 `keepWhileDetached: true`: out is not gone. Nothing is
157
+ // scheduled; the table waits to be put back (or for `dispose()`).
158
+ const grace = table._detachGrace();
159
+ if (grace === Infinity) continue;
139
160
  // NOT AT ONCE. A renderer that takes a subtree out and puts it back in
140
161
  // the same task (FlexDesk's own tile renderer re-appends every leaf in
141
162
  // tree order) has put it back by the time this microtask runs — and one
142
- // that takes a turn longer still gets one task's grace. Only a table
143
- // that is STILL out after that has been removed from the page.
163
+ // that takes a turn longer still gets one task's grace (or, 0.7.0, the
164
+ // `keepWhileDetached` grace). Only a table that is STILL out after that
165
+ // has been removed from the page.
144
166
  table._autoPending = true;
145
- setTimeout(() => {
167
+ table._autoTimer = setTimeout(() => {
146
168
  table._autoPending = false;
169
+ table._autoTimer = null;
147
170
  if (!entry.tables.has(ref)) return;
148
171
  const now = table._wrapperEl;
149
172
  // `_disposeOwn`, NOT `dispose()`: by now the host may hold
150
173
  // something else — the next list mounted into it, or the pane's
151
174
  // own message — and `dispose()` empties the host.
152
175
  if (now && !now.isConnected) table._disposeOwn();
153
- }, 0);
176
+ }, grace);
154
177
  }
155
178
  if (entry.tables.size === 0) autoDisposeRelease(entry);
156
179
  }
@@ -197,6 +220,49 @@ function perColumn(option, colIdx) {
197
220
  return option;
198
221
  }
199
222
 
223
+ /** 0.7.0. Ids for the detail regions a row's toggle controls. */
224
+ const DETAIL_SEQ = { n: 0 };
225
+
226
+ /** 0.7.0. How a filter control's value matches a cell when its control says
227
+ * nothing (`match`): a list is "one of these", a `{from, to}` is a range
228
+ * (inclusive; dates and numbers compared as such), text is "contains", and
229
+ * anything else is equality as text. */
230
+ function defaultControlMatch(cell, value) {
231
+ if (Array.isArray(value)) return value.some((v) => String(v ?? '') === String(cell ?? ''));
232
+ if (value && typeof value === 'object' && !(value instanceof Date)
233
+ && ('from' in value || 'to' in value)) {
234
+ const asNumber = (x) => {
235
+ if (x == null || x === '') return null;
236
+ if (x instanceof Date) return x.getTime();
237
+ if (typeof x === 'number') return x;
238
+ const t = parseDateValue(x);
239
+ if (Number.isFinite(t)) return t;
240
+ const n = parseFloat(x);
241
+ return Number.isFinite(n) ? n : null;
242
+ };
243
+ const c = asNumber(cell);
244
+ if (c === null) return false;
245
+ const lo = asNumber(value.from);
246
+ const hi = asNumber(value.to);
247
+ if (lo !== null && c < lo) return false;
248
+ if (hi !== null && c > hi) return false;
249
+ return true;
250
+ }
251
+ if (typeof value === 'string') {
252
+ return String(cell ?? '').toLowerCase().includes(value.toLowerCase());
253
+ }
254
+ return String(value) === String(cell ?? '');
255
+ }
256
+
257
+ /** 0.7.0. The chips' rule: the cell IS the chosen value (one of them, for
258
+ * `multiple`). */
259
+ function chipsMatch(spec) {
260
+ return (cell, value) => {
261
+ const want = Array.isArray(value) ? value : [value];
262
+ return want.some((v) => String(v ?? '') === String(cell ?? ''));
263
+ };
264
+ }
265
+
200
266
  /** One sort key, comparable with `compareSortKeys`. */
201
267
  function normaliseSortKey(k) {
202
268
  if (k == null) return null;
@@ -302,6 +368,26 @@ const AUTOSIZE_MAX_PX = 520;
302
368
  * `total` — the source's exact row count, or null. `atLeast` — a lower bound when the exact count is not known (a capped count). `hasMore` — the source has rows past those held.
303
369
  * `offset` — in pages, where the first row held stands in the source: the strip's "Rows 1,001–2,000" and `showRowNumbers: 'position'` read it. The rows' indexes (`__rowIndex`, selection, `onRowClick`) stay those of `rows`.
304
370
  * `onPage(offset, limit)` — pages: show the page at `offset`. `onLoadMore()` — scroll: called when the body is scrolled within 400px of its end (`loadMorePx`), or does not fill its box, while `hasMore`. `onLoadAll()` — scroll: the strip's *Load all rows* button. `onModeChange(mode)` — the strip's Pages / Scroll switch; without it there is no switch. Each may return a Promise; until it settles no second call is made and its button shows the table is busy.
371
+ * 0.7.0: WITH `dataSource` the table fetches, and is its own `paging` consumer — `offset`, `total`, `atLeast`, `hasMore` and `held` are read from the source's answers, `onPage`/`onLoadMore`/`onLoadAll` are the table's own questions (`reason` 'page', 'more', 'all'; more rows are drawn under those held), and `onModeChange` is told after the table has switched and asked again (`reason` 'mode'). The 0.7.0 pager is not drawn; `pagerLabels` and `maxJumpOffset` apply to the strip.
372
+ *
373
+ * ── 0.7.0, every one of them off unless asked for ─────────────────────────
374
+ * @property {Function} [rowDetail] - `(row, rowIdx, {host, key, table}) => Node|string|null|void`. A row's DETAIL, folded open under it: each row (or each row `hasRowDetail` admits) gets a toggle at the start of its first cell, and opening it draws a full-width row under the row — the function fills `host` or returns what to put in it. It is drawn when opened and dropped when closed. Keyboard: the toggle is a button (Enter/Space), and on the focused table ArrowRight opens and ArrowLeft closes the current row's detail. Which rows are open is kept by KEY (`getRowKey`, else the row's index), so it survives a sort, a filter, a page and a `setData`; under `virtualize` an open detail is part of its row's height.
375
+ * @property {Function} [hasRowDetail] - `(row, rowIdx) => boolean`. Whether a row has a detail at all; a row without one gets no toggle. Absent: every row has one.
376
+ * @property {Array} [expandedRows] - The keys of the rows open at first.
377
+ * @property {Function} [onRowDetailToggle] - `(rowIdx, row, expanded, key) => void`, after a detail opened or closed by a gesture or by `setRowExpanded`.
378
+ * @property {string} [rowDetailLabel='Details'] - The toggle's accessible name (its state is `aria-expanded`).
379
+ * @property {Function} [dataSource] - `(query, {signal}) => Promise<{rows, total?, hasMore?, filterCounts?} | rows>`. ROWS FROM A SERVER. The table asks for a page whenever its sort, its column filters or its page change (and on `reload()`/`refresh()`), and draws the answer AS GIVEN — no client sort, filter or slice. `query` is `{offset, limit, sort: {column, header, ascending}|null, filters: [{column, header, value}], reason}`. With `paging` the rows may be shown as one scrolling list as well as in pages — see `paging`. Only the NEWEST request paints: an older answer arriving later is dropped, and its `signal` is aborted. A failure keeps the rows on screen under `setError`'s banner. `total` is the count under these filters — a number, or null when it is not known (see `hasMore`). `rowIdx` everywhere is the row's index in the page drawn.
380
+ * @property {Function} [onLoad] - `(result, query) => void`, after the newest answer has been drawn.
381
+ * @property {Function} [onLoadError] - `(error, query) => void`, when the newest request failed (an aborted one is not a failure).
382
+ * @property {boolean} [hasMore] - Server-side paging (`onPageChange`) with NO total: with `totalCount` null and this a boolean, the pager offers Next exactly while it is true and has no last page. Under `dataSource` the answer's `hasMore` (or, absent, a full page) says it.
383
+ * @property {Object} [pagerLabels] - The pager's words: `{first, prev, next, last, noLast, tooDeep}`. The four buttons are drawn with their word beside the icon, never narrower than it (`{first: 'Newest', prev: 'Newer', next: 'Older', last: 'Oldest'}` for a log read newest first); `noLast` is the refusal on Last when the total is unknown, `tooDeep` (`(max) => string`) the one past `maxJumpOffset`.
384
+ * @property {'top'|'bottom'} [pagerPosition='top'] - Where the pager strip sits.
385
+ * @property {number} [maxJumpOffset] - The deepest offset Last and Go-to-row will jump to. Past it the control is REFUSED, disabled with the reason on it (`data-refusal`), rather than slow.
386
+ * @property {boolean|{interval?: number}} [follow=false] - Under `dataSource`: re-read the first page every `interval` ms (default 10000) while the table is on it and in the page, keeping the scroll position, the selection and the active row; rows not there before are drawn `twm-dt-row--new`. A tick waits while text in the table is selected or the focus is inside an open detail. Paging away turns following OFF (`onFollowChange(false)`); coming back does not turn it on again. See `setFollow`, `refresh`.
387
+ * @property {Function} [onFollowChange] - `(following) => void`.
388
+ * @property {boolean|number} [keepWhileDetached=false] - With `autoDispose`: how long a table taken out of the page may stay out and still come back drawn. A number is a grace in ms; `true` keeps it for as long as it is out (it is disposed only by `dispose()`, and what it holds meanwhile is its own DOM — nothing on the document, and a follow timer that holds it weakly). A table put back within its grace is not disposed, however many tasks it was out.
389
+ * @property {Object|Array|Function} [filterKinds] - FILTER CONTROLS THAT ARE NOT TEXT BOXES, per column (an object or array keyed by column index, or `(colIdx, header) => spec`). A spec is `'text'` (the 0.4 box), `{kind: 'chips', options: [{value, label, icon?, title?}], multiple?}` (one-of-N chips with counts — an option whose value is `''` or null means "no filter"), or `{create(ctx)}` / a function `create(ctx)` for the consumer's own control (a date range, a person): `ctx` is `{column, header, value, counts, setValue(v), table}`, and it returns `{el, setValue?(v), setCounts?(c), destroy?(), match?(cellValue, filterValue, row)}`. A filter's value may then be anything (an array, an object): `query.filters` forwards it as it is, and on the client the control's `match` decides (chips: equality, or membership for `multiple`). The control is built once and kept across renders, and is in the column-width measure: a column is never narrower than its control at its narrowest (the widest chip), and the cell wraps the rest.
390
+ * @property {Function} [onFilterChange] - `(filters) => void` with `[{column, header, value}]`, whenever a column filter changes.
305
391
  */
306
392
 
307
393
  /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
@@ -402,9 +488,40 @@ export class DataTable {
402
488
  dateTimeFormat: null,
403
489
  durationFormat: null,
404
490
  dateTimeZone: 'local',
491
+ // 0.7.0. Off — or exactly the 0.6 behaviour — until set. The
492
+ // typedef says what each does.
493
+ rowDetail: null,
494
+ hasRowDetail: null,
495
+ expandedRows: null,
496
+ onRowDetailToggle: null,
497
+ rowDetailLabel: 'Details',
498
+ dataSource: null,
499
+ onLoad: null,
500
+ onLoadError: null,
501
+ hasMore: undefined,
502
+ pagerLabels: null,
503
+ pagerPosition: 'top',
504
+ maxJumpOffset: null,
505
+ follow: false,
506
+ onFollowChange: null,
507
+ keepWhileDetached: false,
508
+ filterKinds: null,
509
+ onFilterChange: null,
405
510
  ...config,
406
511
  };
407
512
 
513
+ // 0.7.0. Open details (by key), the server's half, follow, and the
514
+ // filter controls that are not text boxes.
515
+ this._expanded = new Set((this.config.expandedRows || []).map((k) => String(k)));
516
+ this._detailHeights = new Map();
517
+ this._source = null;
518
+ this._following = !!this.config.follow && !!this.config.dataSource;
519
+ this._followTimer = null;
520
+ this._newKeys = null;
521
+ this._filterControls = new Map();
522
+ this._filterCounts = new Map();
523
+ this._pendingFilter = null;
524
+
408
525
  // 0.5.0. The open row's key, and the failure on show (`setError`).
409
526
  this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
410
527
  this._error = null;
@@ -579,6 +696,20 @@ export class DataTable {
579
696
  this._state.filters.clear();
580
697
  }
581
698
  }
699
+ // 0.7.0. A filter control belongs to a column; new columns, new
700
+ // controls (and no counts carried onto a column they did not count).
701
+ if (updates.headers && (this._filterControls.size || this._filterCounts.size)) {
702
+ const oldHeaders = this.config.headers;
703
+ const newHeaders = updates.headers;
704
+ if (oldHeaders.length !== newHeaders.length
705
+ || oldHeaders.some((h, i) => h !== newHeaders[i])) {
706
+ this._destroyFilterControls();
707
+ this._filterCounts.clear();
708
+ }
709
+ }
710
+ if (Object.prototype.hasOwnProperty.call(updates, 'filterKinds')) {
711
+ this._destroyFilterControls();
712
+ }
582
713
 
583
714
  Object.assign(this.config, updates);
584
715
 
@@ -630,7 +761,8 @@ export class DataTable {
630
761
  // table with an `emptyState`, the one thing that reads it: a table that
631
762
  // asks for nothing new keeps 0.4's wrapper to the attribute.
632
763
  this._wrapperEl.classList.toggle('twm-data-table-component--loading',
633
- !!on && this.config.emptyState !== undefined);
764
+ !!on && (this.config.emptyState !== undefined
765
+ || !!this.config.dataSource));
634
766
  let overlay = this._wrapperEl.querySelector('.twm-data-table__loading');
635
767
  if (on) {
636
768
  if (!overlay) {
@@ -763,6 +895,17 @@ export class DataTable {
763
895
  * @param {number} page - Page number (0-indexed)
764
896
  */
765
897
  goToPage(page) {
898
+ // 0.7.0 `dataSource`: the page is the SOURCE's — asked for, and drawn
899
+ // when it answers (the promise). Past a known total, the last page.
900
+ // (The release review: this set an offset nothing under a data source
901
+ // reads, and drew the same page again.)
902
+ if (this.config.dataSource) {
903
+ const size = this._sourcePageSize();
904
+ let p = Math.max(0, Math.floor(Number(page)) || 0);
905
+ const total = this._source?.total;
906
+ if (typeof total === 'number') p = Math.min(p, Math.max(0, Math.ceil(total / size) - 1));
907
+ return this._sourceGoTo(p * size);
908
+ }
766
909
  const rowCount = this._getProcessedRows().length;
767
910
  const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
768
911
  this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
@@ -774,6 +917,11 @@ export class DataTable {
774
917
  * @param {number} rowIndex - Row index
775
918
  */
776
919
  goToRow(rowIndex) {
920
+ // 0.7.0 `dataSource`: the page that holds the row, from the source.
921
+ if (this.config.dataSource) {
922
+ const size = this._sourcePageSize();
923
+ return this.goToPage(Math.floor(Math.max(0, Math.floor(Number(rowIndex)) || 0) / size));
924
+ }
777
925
  const rowCount = this._getProcessedRows().length;
778
926
  const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
779
927
  this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
@@ -811,6 +959,9 @@ export class DataTable {
811
959
  this._awaitWithSpinner(ret);
812
960
  this._savePersisted();
813
961
  this.render();
962
+ // 0.7.0 `dataSource`: a new order is a new question, from its first
963
+ // page. Drawn first (the arrow moves at once), asked after.
964
+ if (this.config.dataSource) this._sourceLoad({ offset: 0, reason: 'sort' });
814
965
  }
815
966
 
816
967
  /** Internal: if `maybePromise` is a Promise (or thenable),
@@ -829,7 +980,18 @@ export class DataTable {
829
980
  this._processedRows = null;
830
981
  this._processedIndexMap = null;
831
982
  this._state.offset = 0;
983
+ this._pendingFilter = null;
832
984
  this._savePersisted();
985
+ // 0.7.0. Each control shows "no filter", and whoever listens hears it.
986
+ for (const { control } of this._filterControls.values()) {
987
+ try { control.setValue?.(null); } catch (err) { console.error('[DataTable] filter control setValue threw', err); }
988
+ }
989
+ this._notifyFilterChange();
990
+ if (this.config.dataSource) {
991
+ if (this._wrapperEl) this.render();
992
+ this._sourceLoad({ offset: 0, reason: 'filter' });
993
+ return;
994
+ }
833
995
  this.render();
834
996
  }
835
997
 
@@ -854,6 +1016,12 @@ export class DataTable {
854
1016
  // navigation silently died the first time the store moved.
855
1017
  const doc = this.container.ownerDocument;
856
1018
  const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
1019
+ // 0.7.0. Focus inside a filter control (it is kept and re-appended,
1020
+ // which a move would blur) or on a row's detail toggle (rebuilt): put
1021
+ // back after the render. And a filter still being typed under
1022
+ // `dataSource` — an answer arriving mid-word re-renders the row.
1023
+ const focus070 = this._captureFocus070(doc);
1024
+ const pendingFilter = this.config.dataSource ? this._pendingFilter : null;
857
1025
  // SCROLL POSITION SURVIVES A RE-RENDER, for the same reason: the scroll
858
1026
  // box is rebuilt too, and a new one starts at the top. Folding a tree
859
1027
  // row, or any refresh, threw a reader scrolled halfway down back to row
@@ -914,9 +1082,14 @@ export class DataTable {
914
1082
  // Pagination (top). `_createPagination` returns null when the
915
1083
  // strip would be uninformative (single page, no active filter). Not
916
1084
  // with `paging`, whose own strip (under the body) replaces it.
917
- if (pagination && rows.length > 0 && !this._pagingConfig()) {
1085
+ // 0.7.0: under `dataSource` an empty page that is not the first still
1086
+ // needs it — it is the way back.
1087
+ this._paginationEl = null;
1088
+ if (pagination && (rows.length > 0 || (this._source?.offset || 0) > 0) && !this._pagingConfig()) {
918
1089
  this._paginationEl = this._createPagination();
919
- if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
1090
+ if (this._paginationEl && this.config.pagerPosition !== 'bottom') {
1091
+ this._wrapperEl.appendChild(this._paginationEl);
1092
+ }
920
1093
  }
921
1094
 
922
1095
  // ── Structural split: thead and tbody live in separate
@@ -939,7 +1112,12 @@ export class DataTable {
939
1112
  this._wrapperEl.appendChild(this._createErrorBanner());
940
1113
  }
941
1114
 
942
- if (rows.length === 0) {
1115
+ // 0.7.0. Under `dataSource` the rows ARE the filtered answer, so an
1116
+ // empty one must keep the filter row — or a filter that matched nothing
1117
+ // could never be cleared. It goes through `_createTable` and gets its
1118
+ // empty row there.
1119
+ const keepFilterRow = rows.length === 0 && !!this.config.dataSource && this.config.filterable;
1120
+ if (rows.length === 0 && !keepFilterRow) {
943
1121
  // Empty state — render the same chrome as the data table
944
1122
  // (header row in its own non-scrolling wrap) followed by a
945
1123
  // single virtual row spanning every column with the empty
@@ -979,30 +1157,7 @@ export class DataTable {
979
1157
  const bodyTable = document.createElement('table');
980
1158
  bodyTable.className = headerTable.className;
981
1159
  const tbody = document.createElement('tbody');
982
- const emptyTr = document.createElement('tr');
983
- emptyTr.className = 'data-preview-row data-table__empty-row';
984
- const colCount = headers.length + (showRowNumbers ? 1 : 0);
985
- const emptyTd = document.createElement('td');
986
- emptyTd.colSpan = colCount;
987
- if (this._error) {
988
- // 0.5.0. Nothing to keep: the failure takes the empty row.
989
- emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--error';
990
- emptyTd.setAttribute('role', 'alert');
991
- this._appendStateContent(emptyTd, this._errorContent());
992
- } else if (this.config.emptyState !== undefined) {
993
- // 0.5.0. The consumer's own words (or node), styled by class
994
- // rather than inline, and quiet while the table is loading.
995
- emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
996
- const state = typeof this.config.emptyState === 'function'
997
- ? this.config.emptyState() : this.config.emptyState;
998
- this._appendStateContent(emptyTd, state);
999
- } else {
1000
- emptyTd.style.cssText
1001
- = 'text-align:center; font-style:italic; color:#888; padding:16px;';
1002
- emptyTd.textContent = emptyMessage;
1003
- }
1004
- emptyTr.appendChild(emptyTd);
1005
- tbody.appendChild(emptyTr);
1160
+ tbody.appendChild(this._buildEmptyRow(headers.length + (showRowNumbers ? 1 : 0), emptyMessage));
1006
1161
  bodyTable.appendChild(tbody);
1007
1162
  tableWrap.appendChild(bodyTable);
1008
1163
  this._wrapperEl.appendChild(tableWrap);
@@ -1031,11 +1186,24 @@ export class DataTable {
1031
1186
  } else {
1032
1187
  this._headerTableEl = null;
1033
1188
  }
1189
+ // 0.7.0 `keepFilterRow`: the header and its filters, then the
1190
+ // empty (or failed) row the other branch would have drawn.
1191
+ if (keepFilterRow && this._tbodyEl) {
1192
+ const n = this.config.headers.length + (this.config.showRowNumbers ? 1 : 0);
1193
+ this._tbodyEl.appendChild(this._buildEmptyRow(n, emptyMessage));
1194
+ }
1034
1195
  tableWrap.appendChild(fullTable);
1035
1196
  this._wrapperEl.appendChild(tableWrap);
1036
1197
  }
1037
1198
 
1038
1199
  this._tableWrapEl = tableWrap;
1200
+ // 0.7.0 `pagerPosition: 'bottom'`: under the rows.
1201
+ if (this._paginationEl && this.config.pagerPosition === 'bottom') {
1202
+ this._paginationEl.classList.add('twm-pagination-controls--bottom');
1203
+ this._paginationEl.style.borderBottom = '0';
1204
+ this._paginationEl.style.borderTop = '1px solid #2a2a2a';
1205
+ this._wrapperEl.appendChild(this._paginationEl);
1206
+ }
1039
1207
  // `paging`: the strip goes UNDER the body, where the reader's eye is
1040
1208
  // when they reach the end of what is held.
1041
1209
  this._pagingEl = null;
@@ -1119,7 +1287,8 @@ export class DataTable {
1119
1287
 
1120
1288
  // Install interactions (readonly only prevents editing, not selection/copy)
1121
1289
  if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
1122
- || typeof this.config.onRowActivate === 'function')) {
1290
+ || typeof this.config.onRowActivate === 'function'
1291
+ || typeof this.config.rowDetail === 'function')) {
1123
1292
  this._installInteractions();
1124
1293
  }
1125
1294
 
@@ -1130,6 +1299,25 @@ export class DataTable {
1130
1299
  else if (hadTableFocus && this._tableEl) {
1131
1300
  try { this._tableEl.focus({ preventScroll: true }); } catch (_) { /* detached */ }
1132
1301
  }
1302
+ else if (focus070) {
1303
+ this._restoreFocus070(focus070);
1304
+ }
1305
+ // 0.7.0. The words still being typed, and their debounce, which
1306
+ // `_cleanup` cleared.
1307
+ if (pendingFilter) this._resumePendingFilter(pendingFilter);
1308
+
1309
+ // 0.7.0 `dataSource`: the first render asks for the first page; a
1310
+ // render while a request is out keeps the overlay up; following
1311
+ // resumes on a table drawn again.
1312
+ if (this.config.dataSource) {
1313
+ if (!this._source?.started) {
1314
+ this._sourceState().started = true;
1315
+ Promise.resolve().then(() => this._sourceLoad({ offset: 0, reason: 'initial' }));
1316
+ } else if (this._source.inFlight && !this._source.pendingAppend) {
1317
+ this.setLoading(true);
1318
+ }
1319
+ if (this._following && !this._followTimer) this._armFollow();
1320
+ }
1133
1321
  }
1134
1322
 
1135
1323
  /**
@@ -1257,6 +1445,19 @@ export class DataTable {
1257
1445
  _teardown(emptyHost) {
1258
1446
  autoDisposeUnwatch(this);
1259
1447
  this._autoSeen = false;
1448
+ // 0.7.0. A grace still running, the follow timer, a request still out
1449
+ // (its answer must not draw into a disposed table), and the filter
1450
+ // controls (a picker may hang a menu on the body).
1451
+ if (this._autoTimer) { clearTimeout(this._autoTimer); this._autoTimer = null; }
1452
+ this._autoPending = false;
1453
+ this._disarmFollow();
1454
+ if (this._source) {
1455
+ this._source.ticket++;
1456
+ try { this._source.controller?.abort(); } catch (_) { /* gone */ }
1457
+ this._source.controller = null;
1458
+ this._source.inFlight = false;
1459
+ }
1460
+ this._destroyFilterControls();
1260
1461
  this._runOffs(this._activationOffs);
1261
1462
  this._activationOffs = [];
1262
1463
  this._cleanup();
@@ -1295,7 +1496,7 @@ export class DataTable {
1295
1496
  _getProcessedRows() {
1296
1497
  if (this._processedRows !== null) return this._processedRows;
1297
1498
 
1298
- const isServerSide = typeof this.config.onPageChange === 'function';
1499
+ const isServerSide = this._isServerSide();
1299
1500
  let rows = this.config.rows;
1300
1501
  let indexMap = rows.map((_, i) => i);
1301
1502
 
@@ -1322,6 +1523,9 @@ export class DataTable {
1322
1523
  const filters = this._state.filters;
1323
1524
  const filteredRows = [];
1324
1525
  const filteredMap = [];
1526
+ // 0.7.0: each column's control rule, asked once rather than per row.
1527
+ const matchers = new Map();
1528
+ for (const colIdx of filters.keys()) matchers.set(colIdx, this._filterMatcher(colIdx));
1325
1529
 
1326
1530
  for (let i = 0; i < rows.length; i++) {
1327
1531
  const row = rows[i];
@@ -1330,6 +1534,14 @@ export class DataTable {
1330
1534
  for (const [colIdx, filterText] of filters) {
1331
1535
  if (!filterText) continue;
1332
1536
  const value = row[colIdx];
1537
+ // 0.7.0 `filterKinds`: a control that is not a text box decides
1538
+ // by its own rule, over a value that need not be text.
1539
+ const kindMatch = matchers.get(colIdx);
1540
+ if (kindMatch) {
1541
+ if (this._isEmptyFilterValue(filterText)) continue;
1542
+ if (!kindMatch(value, filterText, row)) { pass = false; break; }
1543
+ continue;
1544
+ }
1333
1545
  const colType = this._valueKind(colIdx);
1334
1546
 
1335
1547
  if (colType === 'num') {
@@ -1520,6 +1732,20 @@ export class DataTable {
1520
1732
  const th = document.createElement('th');
1521
1733
  th.className = 'data-table__filter-cell';
1522
1734
 
1735
+ // 0.7.0 `filterKinds`: the consumer's (or the built-in chips')
1736
+ // control in place of the text box — the SAME element every render.
1737
+ const control = this._filterControlFor(colIdx, header);
1738
+ if (control) {
1739
+ th.classList.add('data-table__filter-cell--control');
1740
+ // The measuring pass keeps a row holding a control (its text
1741
+ // boxes stay out): see `_measureNaturalWidths`.
1742
+ tr.classList.add('twm-data-table__filter-row--controls');
1743
+ th.addEventListener('click', (e) => e.stopPropagation());
1744
+ if (control.el) th.appendChild(control.el);
1745
+ tr.appendChild(th);
1746
+ return;
1747
+ }
1748
+
1523
1749
  const wrapper = document.createElement('div');
1524
1750
  wrapper.className = 'twm-data-table__filter-wrapper';
1525
1751
 
@@ -1868,17 +2094,25 @@ export class DataTable {
1868
2094
  if (this._filterDebounceTimer) {
1869
2095
  clearTimeout(this._filterDebounceTimer);
1870
2096
  }
2097
+ // 0.7.0. What is waiting, so a render under `dataSource` (an answer
2098
+ // arriving mid-word) can put it back — see `_resumePendingFilter`.
2099
+ this._pendingFilter = { colIdx, value };
1871
2100
  this._filterDebounceTimer = setTimeout(() => {
2101
+ this._pendingFilter = null;
1872
2102
  this._onFilterInput(colIdx, value);
1873
2103
  }, 200);
1874
2104
  }
1875
2105
 
1876
2106
  _onFilterInput(colIdx, value) {
1877
- if (value) {
2107
+ // 0.7.0. A filter value need not be text (a control's array, a range),
2108
+ // and an empty one of those is no filter. Text is judged as in 0.4.
2109
+ const empty = typeof value === 'string' ? !value : this._isEmptyFilterValue(value);
2110
+ if (!empty) {
1878
2111
  this._state.filters.set(colIdx, value);
1879
2112
  } else {
1880
2113
  this._state.filters.delete(colIdx);
1881
2114
  }
2115
+ this._pendingFilter = null;
1882
2116
 
1883
2117
  this._invalidateProcessedCache();
1884
2118
  this._state.offset = 0;
@@ -1886,9 +2120,40 @@ export class DataTable {
1886
2120
  this._state.anchorIndex = null;
1887
2121
 
1888
2122
  this._savePersisted();
2123
+ // 0.7.0. The column's control shows the value, and listeners hear it.
2124
+ const entry = this._filterControls.get(colIdx);
2125
+ if (entry) {
2126
+ try { entry.control.setValue?.(empty ? null : value); } catch (err) {
2127
+ console.error('[DataTable] filter control setValue threw', err);
2128
+ }
2129
+ }
2130
+ this._notifyFilterChange();
2131
+ // 0.7.0 `dataSource`: the server filters. Nothing to redraw until it
2132
+ // answers (the box shows what was typed); a control's own state
2133
+ // changed, so it repaints itself.
2134
+ if (this.config.dataSource) {
2135
+ this._sourceLoad({ offset: 0, reason: 'filter' });
2136
+ return;
2137
+ }
1889
2138
  this.render();
1890
2139
  }
1891
2140
 
2141
+ /** 0.7.0. Put back a filter that was still being typed when a render
2142
+ * rebuilt its box: the words, and the debounce that will apply them. */
2143
+ _resumePendingFilter({ colIdx, value }) {
2144
+ const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
2145
+ const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
2146
+ const input = cell?.querySelector('.twm-data-table__filter-input');
2147
+ if (input && input.value !== value) {
2148
+ const focused = input.ownerDocument?.activeElement === input;
2149
+ input.value = value;
2150
+ const clear = cell.querySelector('.twm-data-table__filter-clear');
2151
+ if (clear) clear.style.display = value ? '' : 'none';
2152
+ if (focused) input.setSelectionRange(value.length, value.length);
2153
+ }
2154
+ this._onFilterInputDebounced(colIdx, value);
2155
+ }
2156
+
1892
2157
  _getActiveFilterColIdx() {
1893
2158
  const active = document.activeElement;
1894
2159
  if (!active || !active.classList.contains('twm-data-table__filter-input')) return null;
@@ -1992,6 +2257,10 @@ export class DataTable {
1992
2257
  }
1993
2258
 
1994
2259
  _createPagination() {
2260
+ // 0.7.0. Any of the new pager's options takes the new pager, which
2261
+ // draws the 0.6 strip's words for a known total and something honest
2262
+ // for an unknown one. Without them, the 0.6 strip below, untouched.
2263
+ if (this._usesPager070()) return this._createPager070();
1995
2264
  const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
1996
2265
 
1997
2266
  // Server-side pagination uses config offset, client-side uses state offset
@@ -2152,6 +2421,33 @@ export class DataTable {
2152
2421
  if (!p || typeof p !== 'object') return null;
2153
2422
  const size = Number.isFinite(p.pageSize) && p.pageSize >= 1
2154
2423
  ? Math.floor(p.pageSize) : this.config.pageSize;
2424
+ // 0.7.0 — `paging` OVER A `dataSource`. ONE STRIP, ONE SET OF RULES:
2425
+ // the facts the 0.6.2 consumer hands in (`offset`, `total`, `hasMore`,
2426
+ // the rows held) are the source's last answer here, and the four
2427
+ // callbacks the consumer would write are the table's own — a page turn,
2428
+ // the next rows, all of them, the other mode — so the strip, the soft
2429
+ // and hard limits, the busy state and the load-near-the-end trigger are
2430
+ // the same code with or without a server the table asks itself.
2431
+ if (this.config.dataSource) {
2432
+ const src = this._source || { offset: 0, total: null, atLeast: 0, hasMore: false };
2433
+ return {
2434
+ ...p,
2435
+ pageSize: size,
2436
+ offset: src.offset || 0,
2437
+ held: this.config.rows?.length || 0,
2438
+ total: src.total,
2439
+ atLeast: src.atLeast || 0,
2440
+ hasMore: !!src.hasMore,
2441
+ loadMorePx: Number.isFinite(p.loadMorePx) ? p.loadMorePx : PAGING_LOAD_MORE_PX,
2442
+ onPage: (offset) => this._sourceGoTo(offset),
2443
+ onLoadMore: () => this._sourceLoadMore(),
2444
+ onLoadAll: () => this._sourceLoadAll(),
2445
+ // The switch is drawn, as in 0.6.2, only for a consumer that
2446
+ // listens; the table switches and then tells it.
2447
+ onModeChange: typeof p.onModeChange === 'function'
2448
+ ? (mode) => this._sourceSetMode(mode) : undefined,
2449
+ };
2450
+ }
2155
2451
  return {
2156
2452
  ...p,
2157
2453
  pageSize: size,
@@ -2186,7 +2482,15 @@ export class DataTable {
2186
2482
  */
2187
2483
  setPaging(patch = {}) {
2188
2484
  if (!this.config.paging) return;
2485
+ const before = this.config.dataSource ? this.pagingState?.mode : null;
2189
2486
  this.config.paging = { ...this.config.paging, ...patch };
2487
+ // 0.7.0: over a `dataSource` the rows held are the table's, so a patch
2488
+ // that changes which way they are shown asks again from the start —
2489
+ // a list scrolled to 3,000 rows is not page 1.
2490
+ if (before && this.pagingState?.mode !== before) {
2491
+ this._sourceLoad({ offset: 0, reason: 'mode' });
2492
+ return;
2493
+ }
2190
2494
  this._refreshPagingStrip();
2191
2495
  this._pagingMaybeLoadMore();
2192
2496
  }
@@ -2242,6 +2546,9 @@ export class DataTable {
2242
2546
  _pagingMaybeLoadMore() {
2243
2547
  const p = this._pagingConfig();
2244
2548
  if (!p || !p.hasMore || this._pagingBusy) return;
2549
+ // 0.7.0: not while the source is answering another question (a sort, a
2550
+ // filter, a refresh) — those rows are about to be replaced.
2551
+ if (this.config.dataSource && this._source?.inFlight) return;
2245
2552
  const state = this.pagingState;
2246
2553
  if (state.mode !== 'scroll' || p.held >= state.hardLimit) return;
2247
2554
  const wrap = this._tableWrapEl;
@@ -2284,10 +2591,10 @@ export class DataTable {
2284
2591
  else if (known > to) of = ` of more than ${n(known - 1)}`;
2285
2592
  info.textContent = held ? `Rows ${n(from)}–${n(to)}${of}` : 'No rows on this page';
2286
2593
 
2287
- const hasPrev = p.offset > 0;
2288
- const hasNext = exact ? p.offset + held < total : p.hasMore;
2289
- const lastOffset = exact && total > 0
2290
- ? Math.floor((total - 1) / pageSize) * pageSize : null;
2594
+ // The same page arithmetic as the pager above the rows (`_pageNav`).
2595
+ const pn = this._pageNav({ offset: p.offset, held, total, hasMore: p.hasMore, pageSize });
2596
+ const { hasPrev, hasNext } = pn;
2597
+ const lastOffset = exact && total > 0 ? pn.lastOffset : null;
2291
2598
  // A disabled button's click is ignored here too: a browser sends none,
2292
2599
  // but a synthetic one (a test, a script) still reaches the listener.
2293
2600
  const go = (offset) => (ev) => {
@@ -2296,26 +2603,36 @@ export class DataTable {
2296
2603
  const nav = document.createElement('span');
2297
2604
  nav.className = 'twm-dt-paging__nav';
2298
2605
  const off = !!busy || typeof p.onPage !== 'function';
2606
+ // 0.7.0: with the new pager's options (`pagerLabels`,
2607
+ // `maxJumpOffset`, or a `dataSource`) the strip's buttons are the
2608
+ // new pager's — words beside the icons, and a refusal said on the
2609
+ // control. Without them, exactly the 0.6.2 buttons.
2610
+ const words = this._usesPager070();
2611
+ const button = (icon, name, tip, disabled, onClick, refusal = null) => (words
2612
+ ? this._pagerButton(icon, name, tip, disabled, onClick, refusal)
2613
+ : this._createPaginationBtn(icon, tip, disabled, onClick));
2299
2614
  nav.append(
2300
- this._createPaginationBtn('first_page', 'First page', off || !hasPrev, go(0)),
2301
- this._createPaginationBtn('chevron_left', 'Previous page', off || !hasPrev,
2615
+ button('first_page', 'first', 'First page', off || !hasPrev, go(0)),
2616
+ button('chevron_left', 'prev', 'Previous page', off || !hasPrev,
2302
2617
  go(Math.max(0, p.offset - pageSize))),
2303
2618
  );
2304
2619
  const page = document.createElement('span');
2305
2620
  page.className = 'twm-dt-paging__page';
2306
- const current = Math.floor(p.offset / pageSize) + 1;
2307
2621
  page.textContent = exact
2308
- ? `Page ${n(current)} of ${n(Math.max(1, Math.ceil(total / pageSize)))}`
2309
- : `Page ${n(current)}`;
2622
+ ? `Page ${n(pn.page)} of ${n(pn.pages)}`
2623
+ : `Page ${n(pn.page)}`;
2310
2624
  nav.append(
2311
2625
  page,
2312
- this._createPaginationBtn('chevron_right', 'Next page', off || !hasNext,
2626
+ button('chevron_right', 'next', 'Next page', off || !hasNext,
2313
2627
  go(p.offset + pageSize)),
2314
2628
  // The last page needs the exact count: without it there is no
2315
2629
  // offset to go to, and the button says so by being off.
2316
- this._createPaginationBtn('last_page',
2317
- lastOffset === null ? 'Last page (the total is not known)' : 'Last page',
2318
- off || !hasNext || lastOffset === null, go(lastOffset)),
2630
+ words
2631
+ ? button('last_page', 'last', 'Last page', off || !hasNext || lastOffset === null,
2632
+ go(lastOffset), pn.lastRefusal)
2633
+ : button('last_page', 'last',
2634
+ lastOffset === null ? 'Last page (the total is not known)' : 'Last page',
2635
+ off || !hasNext || lastOffset === null, go(lastOffset)),
2319
2636
  );
2320
2637
  el.appendChild(nav);
2321
2638
  } else {
@@ -2377,12 +2694,17 @@ export class DataTable {
2377
2694
  }
2378
2695
 
2379
2696
  _createTable() {
2380
- const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
2697
+ const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon } = this.config;
2381
2698
  const { sortColumn, sortAscending } = this._state;
2699
+ // 0.7.0 `dataSource`: a row's index is its place in the page drawn
2700
+ // (`config.rows`), so every lookup by index — selection, copy,
2701
+ // `updateRow` — reads the row it means on any page. The page's offset
2702
+ // is the server's, and only numbers the rows.
2703
+ const configOffset = this.config.dataSource ? 0 : this.config.offset;
2382
2704
 
2383
2705
  // Server-side pagination: rows already represent current page, use config offset for row numbering
2384
2706
  // Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
2385
- const isServerSide = typeof onPageChange === 'function';
2707
+ const isServerSide = this._isServerSide();
2386
2708
  const processedRows = isServerSide ? rows : this._getProcessedRows();
2387
2709
  const offset = isServerSide ? (configOffset || 0) : this._state.offset;
2388
2710
  // NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
@@ -2487,11 +2809,13 @@ export class DataTable {
2487
2809
 
2488
2810
  // 0.5.0 `showRowNumbers: 'position'`: where the first row drawn here
2489
2811
  // stands in the order SHOWN (the page's first row on a later page).
2490
- this._positionBase = isServerSide ? (configOffset || 0)
2812
+ this._positionBase = this.config.dataSource ? (this._source?.offset || 0)
2813
+ : isServerSide ? (configOffset || 0)
2491
2814
  : (this.config.pagination ? offset : 0);
2492
2815
  // `paging`: a page's rows stand at its offset in the SOURCE, which only
2493
- // the numbering reads — their indexes stay those of `rows`.
2494
- const paged = this._pagingConfig();
2816
+ // the numbering reads — their indexes stay those of `rows`. Over a
2817
+ // `dataSource` that offset IS the source's, counted above once.
2818
+ const paged = this.config.dataSource ? null : this._pagingConfig();
2495
2819
  if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
2496
2820
 
2497
2821
  // Body
@@ -2505,7 +2829,10 @@ export class DataTable {
2505
2829
  });
2506
2830
  } else {
2507
2831
  pageRows.forEach((row, localIdx) => {
2508
- tbody.appendChild(this._buildBodyRow(row, globalOf(localIdx), localIdx));
2832
+ const tr = this._buildBodyRow(row, globalOf(localIdx), localIdx);
2833
+ tbody.appendChild(tr);
2834
+ // 0.7.0 `rowDetail`: an open detail goes in right under its row.
2835
+ if (this._detailOn()) this._appendDetailIfOpen(tr, null);
2509
2836
  });
2510
2837
  }
2511
2838
 
@@ -2669,6 +2996,9 @@ export class DataTable {
2669
2996
  const offsets = new Float64Array(n + 1);
2670
2997
  const fn = typeof this.config.virtualRowHeight === 'function'
2671
2998
  ? this.config.virtualRowHeight : null;
2999
+ // 0.7.0 `rowDetail`: an open detail is part of its row's height —
3000
+ // measured once drawn, estimated until then.
3001
+ const details = this._detailOn() && this._expanded.size > 0;
2672
3002
  let y = 0;
2673
3003
  for (let i = 0; i < n; i++) {
2674
3004
  let h = v.base;
@@ -2676,6 +3006,7 @@ export class DataTable {
2676
3006
  const asked = fn(v.globalOf(i), v.base);
2677
3007
  h = Number.isFinite(asked) && asked > 0 ? asked : 0;
2678
3008
  }
3009
+ if (details && h > 0) h += this._detailHeightOf(v.rows[i], v.globalOf(i), v.base);
2679
3010
  heights[i] = h;
2680
3011
  offsets[i] = y;
2681
3012
  y += h;
@@ -2718,6 +3049,14 @@ export class DataTable {
2718
3049
  * larger than half a pixel, so rows of mixed heights cannot make the
2719
3050
  * offsets move under the reader on every scroll.
2720
3051
  *
3052
+ * 0.7.0 `rowDetail`: AN OPEN DETAIL IS PART OF ITS ROW'S HEIGHT, and is
3053
+ * measured at the same moments. It is never one of the rows the median is
3054
+ * taken from (the window's map holds the rows, not their details), so a
3055
+ * tall detail cannot pass for a row; it is read on its own, into the
3056
+ * row's height — and read again here, because a detail's height moves for
3057
+ * the same reasons a row's does (the column widths fixed, a font), and
3058
+ * more: it wraps across the whole table's width.
3059
+ *
2721
3060
  * @returns {boolean} whether the heights changed (the offsets are recomputed).
2722
3061
  */
2723
3062
  _virtMeasure() {
@@ -2731,14 +3070,21 @@ export class DataTable {
2731
3070
  if (h > 0) heights.push(h);
2732
3071
  if (heights.length >= 64) break;
2733
3072
  }
2734
- if (!heights.length) return false;
2735
- heights.sort((a, b) => a - b);
2736
- const h = heights[heights.length >> 1];
2737
- const tolerance = v.measured ? 0.5 : 0.01;
2738
- v.measured = true;
2739
- if (Math.abs(h - v.base) <= tolerance) return false;
2740
- v.base = h;
2741
- this._virtBase = h;
3073
+ const details = this._detailOn() && this._expanded.size > 0
3074
+ && this._virtReadDetailHeights(v.map.values());
3075
+ let base = false;
3076
+ if (heights.length) {
3077
+ heights.sort((a, b) => a - b);
3078
+ const h = heights[heights.length >> 1];
3079
+ const tolerance = v.measured ? 0.5 : 0.01;
3080
+ v.measured = true;
3081
+ if (Math.abs(h - v.base) > tolerance) {
3082
+ v.base = h;
3083
+ this._virtBase = h;
3084
+ base = true;
3085
+ }
3086
+ }
3087
+ if (!base && !details) return false;
2742
3088
  this._virtComputeOffsets();
2743
3089
  return true;
2744
3090
  }
@@ -2818,7 +3164,12 @@ export class DataTable {
2818
3164
  for (let i = start; i < end; i++) if (v.heights[i] > 0) want.push(i);
2819
3165
  const wanted = new Set(want);
2820
3166
  for (const [i, tr] of v.map) {
2821
- if (!wanted.has(i)) { tr.remove(); v.map.delete(i); removed?.push(tr); }
3167
+ if (!wanted.has(i)) {
3168
+ tr.remove();
3169
+ this._removeDetailPieces(tr); // 0.7.0: its detail goes with it
3170
+ v.map.delete(i);
3171
+ removed?.push(tr);
3172
+ }
2822
3173
  }
2823
3174
  v.start = start;
2824
3175
  v.end = end;
@@ -2835,10 +3186,14 @@ export class DataTable {
2835
3186
  const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
2836
3187
  v.map.set(i, tr);
2837
3188
  tbody.insertBefore(tr, ref);
3189
+ // 0.7.0: an open detail between the row and the one after it.
3190
+ if (this._detailOn()) this._appendDetailIfOpen(tr, ref);
2838
3191
  ref = tr;
2839
3192
  added.push(tr);
2840
3193
  }
2841
3194
  added.reverse();
3195
+ // 0.7.0: the details just drawn have a real height now.
3196
+ if (this._detailOn() && added.length) this._virtMeasureDetails(added);
2842
3197
  return added;
2843
3198
  }
2844
3199
 
@@ -3054,6 +3409,9 @@ export class DataTable {
3054
3409
  if (colIdx === 0 && typeof this.config.rowIcon === 'function') {
3055
3410
  this._prependRowIcon(td, this.config.rowIcon(row, globalIdx));
3056
3411
  }
3412
+ // 0.7.0 `rowDetail`: the toggle, in front of everything in the
3413
+ // first data cell.
3414
+ if (colIdx === 0 && this._detailOn()) this._prependDetailToggle(td, tr, row, globalIdx);
3057
3415
 
3058
3416
  // Cell-level right-click hook (P2). Fires before the row-level
3059
3417
  // hook and the built-in context menu — the caller can
@@ -3173,6 +3531,8 @@ export class DataTable {
3173
3531
  // 0.5.0. The row's data changed, so its classes, attributes, key and
3174
3532
  // open mark are asked again (a key paused, a delivery that died).
3175
3533
  this._decorateRow(tr, row, index);
3534
+ // 0.7.0. An open detail is of the row's data, so it is drawn again.
3535
+ if (this._detailOn()) this._redrawDetail(tr);
3176
3536
 
3177
3537
  if (focusedCol >= 0 && tr.children[focusedCol]) {
3178
3538
  tr.children[focusedCol].focus?.();
@@ -3180,6 +3540,1168 @@ export class DataTable {
3180
3540
  return true;
3181
3541
  }
3182
3542
 
3543
+ // ─────────────────────────────────────────────────────────────────
3544
+ // 0.7.0 — a row's detail (#26)
3545
+ // ─────────────────────────────────────────────────────────────────
3546
+ //
3547
+ // A detail is TWO rows after its row: the detail itself (one cell across
3548
+ // every column) and a hidden filler. The filler is the stripe: rows are
3549
+ // striped by `tr:nth-child(even)`, a question about POSITION, and one extra
3550
+ // row would flip the stripe of every row below an open detail. Two keep
3551
+ // the parity — the trick `_virtSpacers` already plays for the window.
3552
+ //
3553
+ // Neither row has a `__rowIndex`, so everything that walks the body for
3554
+ // rows of data (selection, the open mark, `_firstBodyRow`, `getRowElement`,
3555
+ // the activation and the copy menu) steps over them as it steps over a
3556
+ // spacer.
3557
+
3558
+ _detailOn() {
3559
+ return typeof this.config.rowDetail === 'function';
3560
+ }
3561
+
3562
+ _rowHasDetail(row, rowIdx) {
3563
+ const fn = this.config.hasRowDetail;
3564
+ if (typeof fn !== 'function') return true;
3565
+ try { return !!fn(row, rowIdx); } catch (err) {
3566
+ console.error('[DataTable] hasRowDetail threw', err);
3567
+ return false;
3568
+ }
3569
+ }
3570
+
3571
+ /** The toggle: a real `<button>` (so Enter and Space work on it, and it is
3572
+ * a control `onRowActivate` leaves alone), its glyph drawn by CSS so the
3573
+ * cell's text, its tooltip and a copy stay the cell's own. */
3574
+ _prependDetailToggle(td, tr, row, rowIdx) {
3575
+ if (!this._rowHasDetail(row, rowIdx)) {
3576
+ // A row with no detail keeps the toggle's room, so its first cell's
3577
+ // text lines up with the rows that have one.
3578
+ const gap = document.createElement('span');
3579
+ gap.className = 'twm-dt-detail-toggle-gap';
3580
+ gap.setAttribute('aria-hidden', 'true');
3581
+ td.insertBefore(gap, td.firstChild);
3582
+ return;
3583
+ }
3584
+ const key = this._rowKey(row, rowIdx);
3585
+ const open = key !== null && this._expanded.has(key);
3586
+ const btn = document.createElement('button');
3587
+ btn.type = 'button';
3588
+ btn.className = 'material-symbols-outlined twm-dt-detail-toggle';
3589
+ btn.setAttribute('aria-expanded', open ? 'true' : 'false');
3590
+ btn.setAttribute('aria-label', String(this.config.rowDetailLabel || 'Details'));
3591
+ btn.title = String(this.config.rowDetailLabel || 'Details');
3592
+ btn.addEventListener('click', (ev) => {
3593
+ // Its own gesture: it neither selects nor opens the row.
3594
+ ev.preventDefault();
3595
+ ev.stopPropagation();
3596
+ this._setRowElExpanded(tr, btn.getAttribute('aria-expanded') !== 'true', ev);
3597
+ });
3598
+ td.insertBefore(btn, td.firstChild);
3599
+ td.classList.add('twm-dt-cell--has-detail-toggle');
3600
+ tr.classList.add('twm-dt-row--has-detail');
3601
+ }
3602
+
3603
+ /** After `tr` has been placed: its open detail, in front of `ref` (the row
3604
+ * after it, or null for the end of the body). */
3605
+ _appendDetailIfOpen(tr, ref) {
3606
+ const row = tr.__row;
3607
+ const idx = tr.__rowIndex;
3608
+ if (!this._rowHasDetail(row, idx)) return;
3609
+ const key = this._rowKey(row, idx);
3610
+ if (key === null || !this._expanded.has(key)) return;
3611
+ const pieces = this._buildDetailPieces(tr, key);
3612
+ const parent = tr.parentNode;
3613
+ if (!parent) return;
3614
+ for (const p of pieces) parent.insertBefore(p, ref);
3615
+ }
3616
+
3617
+ _buildDetailPieces(tr, key) {
3618
+ const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
3619
+ const detailTr = document.createElement('tr');
3620
+ detailTr.className = 'twm-dt-detail-row';
3621
+ const td = document.createElement('td');
3622
+ td.className = 'twm-dt-detail-cell';
3623
+ td.colSpan = Math.max(1, n);
3624
+ const host = document.createElement('div');
3625
+ host.className = 'twm-dt-detail';
3626
+ DETAIL_SEQ.n += 1;
3627
+ host.id = `twm-dt-detail-${DETAIL_SEQ.n}`;
3628
+ td.appendChild(host);
3629
+ detailTr.appendChild(td);
3630
+ const filler = document.createElement('tr');
3631
+ filler.className = 'twm-dt-detail-filler';
3632
+ filler.hidden = true;
3633
+ filler.setAttribute('aria-hidden', 'true');
3634
+ detailTr.__detailOf = tr;
3635
+ tr.__detail = [detailTr, filler];
3636
+ this._drawDetail(tr, host, key);
3637
+ const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
3638
+ if (btn) btn.setAttribute('aria-controls', host.id);
3639
+ return tr.__detail;
3640
+ }
3641
+
3642
+ _drawDetail(tr, host, key) {
3643
+ host.replaceChildren();
3644
+ let out;
3645
+ try {
3646
+ out = this.config.rowDetail(tr.__row, tr.__rowIndex, { host, key, table: this });
3647
+ } catch (err) {
3648
+ console.error('[DataTable] rowDetail threw', err);
3649
+ out = err?.message || String(err);
3650
+ }
3651
+ if (out == null || out === false || out === host) return;
3652
+ if (typeof Node !== 'undefined' && out instanceof Node) host.appendChild(out);
3653
+ else host.textContent = String(out);
3654
+ }
3655
+
3656
+ _removeDetailPieces(tr) {
3657
+ const pieces = tr.__detail;
3658
+ if (!pieces) return;
3659
+ for (const p of pieces) p.remove();
3660
+ tr.__detail = null;
3661
+ const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
3662
+ if (btn) btn.removeAttribute('aria-controls');
3663
+ }
3664
+
3665
+ /** `updateRow`'s half: the detail follows the row's new data. */
3666
+ _redrawDetail(tr) {
3667
+ const key = this._rowKey(tr.__row, tr.__rowIndex);
3668
+ const want = this._rowHasDetail(tr.__row, tr.__rowIndex) && key !== null && this._expanded.has(key);
3669
+ const pieces = tr.__detail;
3670
+ if (!want) { if (pieces) this._removeDetailPieces(tr); return; }
3671
+ if (pieces) {
3672
+ const host = pieces[0].querySelector('.twm-dt-detail');
3673
+ if (host) {
3674
+ this._drawDetail(tr, host, key);
3675
+ const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
3676
+ if (btn) btn.setAttribute('aria-controls', host.id);
3677
+ }
3678
+ return;
3679
+ }
3680
+ if (tr.parentNode) this._appendDetailIfOpen(tr, tr.nextSibling);
3681
+ }
3682
+
3683
+ /** Open or close the detail of the row `tr` draws. Returns whether
3684
+ * anything changed. */
3685
+ _setRowElExpanded(tr, open, ev = null) {
3686
+ if (!this._detailOn() || tr.__rowIndex === undefined) return false;
3687
+ if (!this._rowHasDetail(tr.__row, tr.__rowIndex)) return false;
3688
+ const key = this._rowKey(tr.__row, tr.__rowIndex);
3689
+ if (key === null || this._expanded.has(key) === !!open) return false;
3690
+ if (open) this._expanded.add(key); else this._expanded.delete(key);
3691
+ this._syncDetailOnScreen(tr, key);
3692
+ this._afterDetailChange();
3693
+ try { this.config.onRowDetailToggle?.(tr.__rowIndex, tr.__row, !!open, key, ev); } catch (err) {
3694
+ console.error('[DataTable] onRowDetailToggle threw', err);
3695
+ }
3696
+ return true;
3697
+ }
3698
+
3699
+ /** Make the row on screen agree with `_expanded` for its key. */
3700
+ _syncDetailOnScreen(tr, key) {
3701
+ const open = this._expanded.has(key);
3702
+ const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
3703
+ if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
3704
+ if (open && !tr.__detail && tr.parentNode) {
3705
+ const ref = tr.nextSibling;
3706
+ for (const p of this._buildDetailPieces(tr, key)) tr.parentNode.insertBefore(p, ref);
3707
+ } else if (!open && tr.__detail) {
3708
+ this._removeDetailPieces(tr);
3709
+ }
3710
+ }
3711
+
3712
+ /** Under `virtualize` an open detail is part of its row's height. */
3713
+ _afterDetailChange() {
3714
+ if (!this._virt) return;
3715
+ this._virtMeasureDetails([...this._virt.map.values()], { force: true });
3716
+ }
3717
+
3718
+ /** The detail's height for row `i` of the window's list: 0 closed, the
3719
+ * measured height open, three rows until it has been measured. */
3720
+ _detailHeightOf(row, rowIdx, base) {
3721
+ if (!this._rowHasDetail(row, rowIdx)) return 0;
3722
+ const key = this._rowKey(row, rowIdx);
3723
+ if (key === null || !this._expanded.has(key)) return 0;
3724
+ const h = this._detailHeights.get(key);
3725
+ return h > 0 ? h : base * 3;
3726
+ }
3727
+
3728
+ /** Measure the open details among `trs` (client pixels, the zoom divided
3729
+ * out, as `_virtMeasure` does), and re-lay the window if one changed. */
3730
+ _virtMeasureDetails(trs, { force = false } = {}) {
3731
+ const v = this._virt;
3732
+ if (!v) return;
3733
+ const changed = this._virtReadDetailHeights(trs) || force;
3734
+ if (!changed) return;
3735
+ this._virtComputeOffsets();
3736
+ this._virtSpacers();
3737
+ }
3738
+
3739
+ /** Read the open details drawn under `trs` into `_detailHeights`; true
3740
+ * when one moved by more than half a pixel. Recomputes nothing. */
3741
+ _virtReadDetailHeights(trs) {
3742
+ let changed = false;
3743
+ const zoom = this._tableEl?.currentCSSZoom || 1;
3744
+ for (const tr of trs) {
3745
+ const d = tr?.__detail?.[0];
3746
+ if (!d || !d.isConnected) continue;
3747
+ const h = d.getBoundingClientRect().height / zoom;
3748
+ if (!(h > 0)) continue;
3749
+ const key = this._rowKey(tr.__row, tr.__rowIndex);
3750
+ if (Math.abs((this._detailHeights.get(key) || 0) - h) > 0.5) {
3751
+ this._detailHeights.set(key, h);
3752
+ changed = true;
3753
+ }
3754
+ }
3755
+ return changed;
3756
+ }
3757
+
3758
+ /**
3759
+ * Open (or close) the detail of the row whose key is `key` (`getRowKey`,
3760
+ * else the row's index). A row not on screen — another page, outside the
3761
+ * window, filtered out — opens when it is drawn.
3762
+ *
3763
+ * @param {*} key
3764
+ * @param {boolean} [open=true]
3765
+ * @returns {boolean} whether anything changed
3766
+ */
3767
+ setRowExpanded(key, open = true) {
3768
+ if (key == null) return false;
3769
+ const k = String(key);
3770
+ if (this._expanded.has(k) === !!open) return false;
3771
+ if (open) this._expanded.add(k); else this._expanded.delete(k);
3772
+ const tr = this._rowElByKey(k);
3773
+ if (tr) {
3774
+ this._syncDetailOnScreen(tr, k);
3775
+ try { this.config.onRowDetailToggle?.(tr.__rowIndex, tr.__row, !!open, k, null); } catch (err) {
3776
+ console.error('[DataTable] onRowDetailToggle threw', err);
3777
+ }
3778
+ }
3779
+ if (this._virt) {
3780
+ this._virtComputeOffsets();
3781
+ this._virtSpacers();
3782
+ this._afterDetailChange();
3783
+ }
3784
+ return true;
3785
+ }
3786
+
3787
+ /** Whether the row whose key is `key` has its detail open. */
3788
+ isRowExpanded(key) {
3789
+ return key != null && this._expanded.has(String(key));
3790
+ }
3791
+
3792
+ /** The keys of the rows whose detail is open, as text. */
3793
+ getExpandedRows() {
3794
+ return [...this._expanded];
3795
+ }
3796
+
3797
+ /** Close every detail. */
3798
+ collapseAllRows() {
3799
+ for (const k of [...this._expanded]) this.setRowExpanded(k, false);
3800
+ }
3801
+
3802
+ /** The `<tr>` on screen for a key, or null. */
3803
+ _rowElByKey(key) {
3804
+ const tbody = this._tbodyEl;
3805
+ if (!tbody) return null;
3806
+ for (const tr of tbody.children) {
3807
+ if (tr.__rowIndex === undefined) continue;
3808
+ if (this._rowKey(tr.__row, tr.__rowIndex) === key) return tr;
3809
+ }
3810
+ return null;
3811
+ }
3812
+
3813
+ // ─────────────────────────────────────────────────────────────────
3814
+ // 0.7.0 — rows from a server (#33), the pager for an unknown total
3815
+ // (#35), refresh and follow (#36)
3816
+ // ─────────────────────────────────────────────────────────────────
3817
+ //
3818
+ // ONE QUESTION AT A TIME, AND ONLY THE NEWEST ANSWER. Every request takes
3819
+ // a ticket; an answer whose ticket is not the latest is dropped, and the
3820
+ // request it answered is aborted the moment a newer one is sent. Two
3821
+ // requests in flight from a fast typist otherwise race, and the loser
3822
+ // painting last is a list that does not match the box above it.
3823
+
3824
+ /** Rows that are the current page of something larger (`onPageChange`,
3825
+ * or 0.7.0's `dataSource`): drawn as given, never filtered, sorted or
3826
+ * sliced here. */
3827
+ _isServerSide() {
3828
+ return typeof this.config.onPageChange === 'function' || !!this.config.dataSource;
3829
+ }
3830
+
3831
+ /** `autoDispose`'s grace in ms: one task, `keepWhileDetached`'s number,
3832
+ * or Infinity for `true`. */
3833
+ _detachGrace() {
3834
+ const k = this.config.keepWhileDetached;
3835
+ if (k === true) return Infinity;
3836
+ if (typeof k === 'number' && Number.isFinite(k) && k > 0) return k;
3837
+ if (k === Infinity) return Infinity;
3838
+ return 0;
3839
+ }
3840
+
3841
+ _sourceState() {
3842
+ if (!this._source) {
3843
+ this._source = {
3844
+ started: false, ticket: 0, controller: null, inFlight: false,
3845
+ offset: 0, pendingOffset: 0, total: null, atLeast: 0, hasMore: false,
3846
+ query: null, loaded: false, pendingAppend: false,
3847
+ };
3848
+ }
3849
+ return this._source;
3850
+ }
3851
+
3852
+ /** The column filters, as a query and `onFilterChange` carry them. */
3853
+ _filterList() {
3854
+ const headers = this.config.headers || [];
3855
+ const out = [];
3856
+ for (const [column, value] of this._state.filters) {
3857
+ if (typeof value === 'string' ? !value : this._isEmptyFilterValue(value)) continue;
3858
+ out.push({ column, header: headers[column], value });
3859
+ }
3860
+ return out.sort((a, b) => a.column - b.column);
3861
+ }
3862
+
3863
+ /** Rows per question: `paging.pageSize` when the source is paged, else
3864
+ * `pageSize` — one number for the query, `goToPage` and the pager. */
3865
+ _sourcePageSize() {
3866
+ const size = this._pagingConfig()?.pageSize ?? this.config.pageSize;
3867
+ return Math.max(1, size | 0);
3868
+ }
3869
+
3870
+ _buildQuery(offset, reason, limit = null) {
3871
+ const col = this._state.sortColumn;
3872
+ return {
3873
+ offset,
3874
+ limit: Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : this._sourcePageSize(),
3875
+ sort: col == null ? null : {
3876
+ column: col, header: (this.config.headers || [])[col], ascending: this._state.sortAscending,
3877
+ },
3878
+ filters: this._filterList(),
3879
+ reason,
3880
+ };
3881
+ }
3882
+
3883
+ /**
3884
+ * Ask the source for the page at `offset`, and draw the answer if it is
3885
+ * still the newest by then. Resolves with the answer drawn, or undefined
3886
+ * when it was dropped (superseded, failed, or the table was disposed).
3887
+ */
3888
+ async _sourceLoad({ offset = 0, reason = 'reload', keep = false, append = false, limit = null } = {}) {
3889
+ const fetch = this.config.dataSource;
3890
+ if (typeof fetch !== 'function') return undefined;
3891
+ const src = this._sourceState();
3892
+ src.started = true;
3893
+ const ticket = ++src.ticket;
3894
+ try { src.controller?.abort(); } catch (_) { /* gone */ }
3895
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
3896
+ src.controller = controller;
3897
+ const query = this._buildQuery(Math.max(0, offset | 0), reason, limit);
3898
+ src.query = query;
3899
+ src.inFlight = true;
3900
+ src.pendingOffset = query.offset;
3901
+ src.pendingAppend = append;
3902
+ // More rows under a scrolling list are said on the paging strip (busy),
3903
+ // not by covering the rows being read.
3904
+ if (!append) this.setLoading(true);
3905
+ let result;
3906
+ try {
3907
+ result = await fetch(query, { signal: controller?.signal });
3908
+ } catch (err) {
3909
+ if (ticket !== src.ticket) return undefined; // superseded or disposed
3910
+ src.inFlight = false;
3911
+ src.controller = null;
3912
+ src.pendingAppend = false;
3913
+ if (err?.name === 'AbortError' || controller?.signal?.aborted) {
3914
+ this.setLoading(false);
3915
+ return undefined;
3916
+ }
3917
+ // THE ROWS ON SCREEN STAY: a failed refresh does not blank a list a
3918
+ // person was reading. `setError` keeps them under its banner.
3919
+ this.setError(err);
3920
+ try { this.config.onLoadError?.(err, query); } catch (e) { console.error('[DataTable] onLoadError threw', e); }
3921
+ return undefined;
3922
+ }
3923
+ if (ticket !== src.ticket) return undefined; // a newer question is out
3924
+ src.inFlight = false;
3925
+ src.controller = null;
3926
+ src.pendingAppend = false;
3927
+ this._applySourceResult(result, query, { keep, append });
3928
+ try { this.config.onLoad?.(result, query); } catch (e) { console.error('[DataTable] onLoad threw', e); }
3929
+ return result;
3930
+ }
3931
+
3932
+ _applySourceResult(result, query, { keep = false, append = false } = {}) {
3933
+ const src = this._sourceState();
3934
+ const r = Array.isArray(result) ? { rows: result } : (result || {});
3935
+ const answered = Array.isArray(r.rows) ? r.rows : [];
3936
+ const totalRaw = r.total ?? r.totalCount;
3937
+ const total = typeof totalRaw === 'number' && Number.isFinite(totalRaw) ? totalRaw : null;
3938
+ const hasMore = typeof r.hasMore === 'boolean' ? r.hasMore
3939
+ : total !== null ? query.offset + answered.length < total
3940
+ : answered.length >= query.limit;
3941
+ // `paging`, scrolling: the next rows go UNDER the ones held, and the
3942
+ // page held still starts where it started.
3943
+ let rows = append ? (this.config.rows || []).concat(answered) : answered;
3944
+ let rowsOffset = append ? (src.offset || 0) : query.offset;
3945
+ // …until the source proves it holds more than the soft limit: from then
3946
+ // on it is shown in PAGES (`resolvePaging`), and the rows the reader had
3947
+ // just reached ARE that page — nothing is asked again, and the reader
3948
+ // goes on from where they were reading.
3949
+ const pc = append ? this._pagingConfig() : null;
3950
+ if (pc && resolvePaging({
3951
+ mode: pc.mode, total, atLeast: r.atLeast, held: rowsOffset + rows.length, hasMore,
3952
+ softLimit: pc.softLimit, hardLimit: pc.hardLimit,
3953
+ }).mode === 'pages') {
3954
+ rows = answered;
3955
+ rowsOffset = query.offset;
3956
+ append = false;
3957
+ }
3958
+ // WITHOUT `getRowKey`, A DETAIL IS OPEN AT A PLACE, and a place in one
3959
+ // answer says nothing about which row stands there in the next: page 2's
3960
+ // first row is not page 1's, and a sort or a new arrival moves every
3961
+ // row. So an index-keyed detail stays open only where the new answer
3962
+ // has the SAME row (equal values) at its place; `expandedRows` applies
3963
+ // to the first answer as given. (The release review: a detail opened on
3964
+ // page 1 opened the same place on page 2, under another row.)
3965
+ if (src.loaded && this._detailOn() && this._expanded.size
3966
+ && typeof this.config.getRowKey !== 'function') {
3967
+ const before = this.config.rows || [];
3968
+ const same = (a, b) => {
3969
+ if (a === b) return true;
3970
+ try { return JSON.stringify(a) === JSON.stringify(b); } catch (_) { return false; }
3971
+ };
3972
+ for (const k of [...this._expanded]) {
3973
+ const i = Number(k);
3974
+ if (!Number.isInteger(i) || i < 0 || i >= rows.length || !same(before[i], rows[i])) {
3975
+ this._expanded.delete(k);
3976
+ this._detailHeights.delete(k);
3977
+ }
3978
+ }
3979
+ }
3980
+ // The page drawn — so the pager says where these rows ARE.
3981
+ src.offset = rowsOffset;
3982
+ src.total = total;
3983
+ src.atLeast = Number.isFinite(r.atLeast) ? r.atLeast : 0;
3984
+ src.hasMore = hasMore;
3985
+ src.loaded = true;
3986
+ if (r.filterCounts && typeof r.filterCounts === 'object') this.setFilterCounts(r.filterCounts);
3987
+
3988
+ const snap = keep ? this._snapshotView() : null;
3989
+ const kept = append ? this._tableWrapEl?.scrollTop || 0 : null;
3990
+ // Rows the last answer did not hold — marked as new (only by key: an
3991
+ // index says nothing about which row is which across two answers).
3992
+ if (snap && snap.keys && snap.keys.size && typeof this.config.getRowKey === 'function') {
3993
+ const fresh = new Set();
3994
+ rows.forEach((row, i) => {
3995
+ const k = this._rowKey(row, i);
3996
+ if (k !== null && !snap.keys.has(k)) fresh.add(k);
3997
+ });
3998
+ this._newKeys = fresh.size ? fresh : null;
3999
+ } else {
4000
+ this._newKeys = null;
4001
+ }
4002
+ // More rows under the same ones: the rows a person chose stay chosen.
4003
+ const chosen = append && this._state.selected.size
4004
+ ? { sel: [...this._state.selected], anchor: this._state.anchorIndex } : null;
4005
+ this.setData({ rows });
4006
+ this._newKeys = null;
4007
+ if (chosen) {
4008
+ for (const i of chosen.sel) this._state.selected.add(i);
4009
+ this._state.anchorIndex = chosen.anchor;
4010
+ this._updateRowSelection();
4011
+ }
4012
+ if (snap) this._restoreView(snap);
4013
+ else if (kept !== null) this._scrollBodyTo(kept); // more rows: the reader stays put
4014
+ else this._scrollBodyTo(0); // a new question starts at the top of its answer
4015
+ }
4016
+
4017
+ /** What a refresh must put back: the keys on screen, the selection by
4018
+ * key, and the first row a reader can see with where it stood. */
4019
+ _snapshotView() {
4020
+ const rows = this.config.rows || [];
4021
+ const keyed = typeof this.config.getRowKey === 'function';
4022
+ const keys = keyed ? new Set(rows.map((row, i) => this._rowKey(row, i))) : null;
4023
+ const selectedKeys = keyed
4024
+ ? this.getSelection().map((i) => this._rowKey(rows[i], i)).filter((k) => k !== null)
4025
+ : null;
4026
+ const wrap = this._tableWrapEl;
4027
+ let anchor = null;
4028
+ const scrollTop = wrap?.scrollTop || 0;
4029
+ if (wrap && scrollTop > 0 && this._tbodyEl) {
4030
+ const zoom = this._tableEl?.currentCSSZoom || 1;
4031
+ const top = wrap.getBoundingClientRect().top;
4032
+ for (const tr of this._tbodyEl.children) {
4033
+ if (tr.__rowIndex === undefined) continue;
4034
+ const box = tr.getBoundingClientRect();
4035
+ if (box.bottom > top + 0.5) {
4036
+ anchor = { key: this._rowKey(tr.__row, tr.__rowIndex), delta: (box.top - top) / zoom };
4037
+ break;
4038
+ }
4039
+ }
4040
+ }
4041
+ return { keys, selectedKeys, anchor, scrollTop };
4042
+ }
4043
+
4044
+ _restoreView(snap) {
4045
+ // The selection, by key: the rows a person chose are still chosen
4046
+ // wherever the new answer put them.
4047
+ if (snap.selectedKeys && snap.selectedKeys.length) {
4048
+ const want = new Set(snap.selectedKeys);
4049
+ const sel = this._state.selected;
4050
+ (this.config.rows || []).forEach((row, i) => {
4051
+ if (want.has(this._rowKey(row, i))) sel.add(i);
4052
+ });
4053
+ if (sel.size) {
4054
+ this._state.anchorIndex = [...sel][0];
4055
+ this._updateRowSelection();
4056
+ this._notifySelectionChange();
4057
+ }
4058
+ }
4059
+ // THE SCROLL POSITION: a reader at the top sees what arrived (that is
4060
+ // following); a reader scrolled down keeps the rows they were reading
4061
+ // where they were — new rows above push the scroll, not the rows.
4062
+ const wrap = this._tableWrapEl;
4063
+ if (!wrap || !snap.anchor || snap.anchor.key === null) return;
4064
+ const rows = this.config.rows || [];
4065
+ const idx = rows.findIndex((row, i) => this._rowKey(row, i) === snap.anchor.key);
4066
+ if (idx < 0) return;
4067
+ const zoom = this._tableEl?.currentCSSZoom || 1;
4068
+ if (this._virt) {
4069
+ const local = this._virtLocalOf(idx);
4070
+ if (local < 0) return;
4071
+ this._scrollBodyTo(Math.max(0, this._virt.offsets[local] - snap.anchor.delta));
4072
+ return;
4073
+ }
4074
+ const tr = this.getRowElement(idx);
4075
+ if (!tr) return;
4076
+ const now = (tr.getBoundingClientRect().top - wrap.getBoundingClientRect().top) / zoom;
4077
+ const shift = now - snap.anchor.delta;
4078
+ if (Math.abs(shift) >= 0.5) this._scrollBodyTo(wrap.scrollTop + shift);
4079
+ }
4080
+
4081
+ _scrollBodyTo(top) {
4082
+ const wrap = this._tableWrapEl;
4083
+ if (!wrap) return;
4084
+ if (Math.abs((wrap.scrollTop || 0) - top) >= 0.5) wrap.scrollTop = top;
4085
+ if (this._virt) this._virtUpdate({ force: true, top });
4086
+ }
4087
+
4088
+ /** Go to the page at `offset` (paging back stops following). */
4089
+ _sourceGoTo(offset) {
4090
+ const next = Math.max(0, offset | 0);
4091
+ // PAGING AWAY TURNS FOLLOWING OFF, and coming back does not turn it on
4092
+ // again by itself: a reader who left the tail to read an old page is
4093
+ // never pulled back to it by the next tick.
4094
+ if (this._following && next > 0) this.setFollow(false);
4095
+ return this._sourceLoad({ offset: next, reason: 'page' });
4096
+ }
4097
+
4098
+ /**
4099
+ * `paging` over a `dataSource`, scrolling: the rows after those held, under
4100
+ * them — never past the hard limit. Scrolling down to ask for them is
4101
+ * paging away, so it turns following off as a page turn does; rows asked
4102
+ * for because the ones held do not fill the box are not (the reader is
4103
+ * still at the top).
4104
+ */
4105
+ _sourceLoadMore() {
4106
+ const held = this.config.rows?.length || 0;
4107
+ const hard = this.pagingState?.hardLimit ?? PAGING_HARD_LIMIT;
4108
+ const limit = Math.min(this._sourcePageSize(), hard - held);
4109
+ if (limit < 1) return Promise.resolve(undefined);
4110
+ if (this._following && (this._tableWrapEl?.scrollTop || 0) > 0) this.setFollow(false);
4111
+ const offset = (this._source?.offset || 0) + held;
4112
+ return this._sourceLoad({ offset, limit, reason: 'more', append: true });
4113
+ }
4114
+
4115
+ /** The strip's *Load all rows*: ONE question for every row left under the
4116
+ * hard limit (`reason: 'all'`); the source answers as many as it will, and
4117
+ * says with `hasMore` whether there are still more. */
4118
+ _sourceLoadAll() {
4119
+ const held = this.config.rows?.length || 0;
4120
+ const hard = this.pagingState?.hardLimit ?? PAGING_HARD_LIMIT;
4121
+ if (hard - held < 1) return Promise.resolve(undefined);
4122
+ if (this._following) this.setFollow(false);
4123
+ const offset = (this._source?.offset || 0) + held;
4124
+ return this._sourceLoad({ offset, limit: hard - held, reason: 'all', append: true });
4125
+ }
4126
+
4127
+ /** The strip's Pages / Scroll switch: the table switches, tells the
4128
+ * consumer (`paging.onModeChange`), and asks again from the start. */
4129
+ _sourceSetMode(mode) {
4130
+ const next = mode === 'pages' ? 'pages' : 'scroll';
4131
+ const p = this.config.paging;
4132
+ this.config.paging = { ...p, mode: next };
4133
+ try { p.onModeChange?.(next); } catch (err) { console.error('[DataTable] onModeChange threw', err); }
4134
+ return this._sourceLoad({ offset: 0, reason: 'mode' });
4135
+ }
4136
+
4137
+ /**
4138
+ * Ask the source again from its FIRST page — for a consumer whose own
4139
+ * filters (outside the table) changed. Under `dataSource` only; resolves
4140
+ * with the answer drawn (undefined when it was superseded or failed).
4141
+ */
4142
+ reload() {
4143
+ if (!this.config.dataSource) return Promise.resolve(undefined);
4144
+ return this._sourceLoad({ offset: 0, reason: 'reload' });
4145
+ }
4146
+
4147
+ /**
4148
+ * Read the CURRENT page again, in place: the scroll position (a reader at
4149
+ * the top sees what arrived; one scrolled down keeps the rows they were
4150
+ * reading where they were), the selection by key, the open row and the
4151
+ * open details all stay; rows the last answer did not hold are drawn
4152
+ * `twm-dt-row--new` (needs `getRowKey`). What `follow` calls on its tick.
4153
+ * Without `dataSource`, it only draws again.
4154
+ */
4155
+ refresh() {
4156
+ if (!this.config.dataSource) {
4157
+ if (this._wrapperEl) this.render();
4158
+ return Promise.resolve(undefined);
4159
+ }
4160
+ const src = this._sourceState();
4161
+ // `paging`, scrolling: the rows HELD are read again, in one question
4162
+ // from where they start — a list scrolled to row 300 is still 300 rows
4163
+ // after the tick. (Following stops once the reader scrolls down for
4164
+ // more, so a following list stays short.)
4165
+ if (this.pagingState?.mode === 'scroll') {
4166
+ const held = this.config.rows?.length || 0;
4167
+ return this._sourceLoad({
4168
+ offset: src.offset || 0, limit: Math.max(this._sourcePageSize(), held),
4169
+ reason: 'refresh', keep: true,
4170
+ });
4171
+ }
4172
+ const offset = src.inFlight && !src.pendingAppend ? src.pendingOffset : src.offset;
4173
+ return this._sourceLoad({ offset, reason: 'refresh', keep: true });
4174
+ }
4175
+
4176
+ /** The last query sent to `dataSource`, or null. */
4177
+ getQuery() {
4178
+ return this._source?.query || null;
4179
+ }
4180
+
4181
+ /** What the last answer said about the whole: `{offset, total, hasMore}`. */
4182
+ getPageInfo() {
4183
+ const s = this._source;
4184
+ return s ? { offset: s.offset, total: s.total, hasMore: s.hasMore } : null;
4185
+ }
4186
+
4187
+ /**
4188
+ * Turn following on or off (`follow`). On, the first page is read again
4189
+ * every `follow.interval` ms while the table is on it and in the page.
4190
+ */
4191
+ setFollow(on) {
4192
+ const next = !!on;
4193
+ if (next === this._following) return;
4194
+ this._following = next;
4195
+ if (next) this._armFollow(); else this._disarmFollow();
4196
+ try { this.config.onFollowChange?.(next); } catch (err) { console.error('[DataTable] onFollowChange threw', err); }
4197
+ }
4198
+
4199
+ isFollowing() {
4200
+ return this._following;
4201
+ }
4202
+
4203
+ _followInterval() {
4204
+ const f = this.config.follow;
4205
+ const ms = f && typeof f === 'object' ? Number(f.interval) : NaN;
4206
+ return Number.isFinite(ms) && ms > 0 ? ms : 10_000;
4207
+ }
4208
+
4209
+ /** The tick holds the table WEAKLY: a follow timer must never be what keeps
4210
+ * a table nobody holds alive (`keepWhileDetached: true`). */
4211
+ _armFollow() {
4212
+ this._disarmFollow();
4213
+ if (!this._following || !this.config.dataSource || !this._wrapperEl) return;
4214
+ const ref = weakRef(this);
4215
+ const id = setInterval(() => {
4216
+ const t = ref.deref();
4217
+ if (!t || t._followTimer !== id) { clearInterval(id); return; }
4218
+ const src = t._source;
4219
+ if (!t._following || !src || src.inFlight) return;
4220
+ if ((src.offset || 0) !== 0) return; // only on the first page
4221
+ if (!t._wrapperEl?.isConnected) return; // not while out of the page
4222
+ if (t._readerIsBusy()) return; // not under a reader (below)
4223
+ t.refresh();
4224
+ }, this._followInterval());
4225
+ this._followTimer = id;
4226
+ }
4227
+
4228
+ _disarmFollow() {
4229
+ if (this._followTimer) clearInterval(this._followTimer);
4230
+ this._followTimer = null;
4231
+ }
4232
+
4233
+ /** A TICK WAITS FOR A READER. A refresh redraws every row and every open
4234
+ * detail, so a tick that came while somebody was selecting text in the
4235
+ * table (a stack trace to copy) or working inside an open detail took the
4236
+ * text out from under the selection and the focus out of the detail —
4237
+ * every interval, for as long as they stayed. The tick is skipped
4238
+ * instead, and following resumes on the next one after (the release
4239
+ * review). `refresh()` called by the consumer is not a tick and does not
4240
+ * wait. */
4241
+ _readerIsBusy() {
4242
+ const wrap = this._tableWrapEl;
4243
+ const doc = wrap?.ownerDocument;
4244
+ if (!wrap || !doc) return false;
4245
+ const active = doc.activeElement;
4246
+ if (active && active !== wrap && wrap.contains(active)
4247
+ && active.closest?.('.twm-dt-detail')) return true;
4248
+ try {
4249
+ const sel = doc.defaultView?.getSelection?.();
4250
+ if (sel && !sel.isCollapsed && sel.rangeCount > 0) {
4251
+ for (let i = 0; i < sel.rangeCount; i++) {
4252
+ const r = sel.getRangeAt(i);
4253
+ if (wrap.contains(r.startContainer) || wrap.contains(r.endContainer)) return true;
4254
+ }
4255
+ }
4256
+ } catch (_) { /* no selection API */ }
4257
+ return false;
4258
+ }
4259
+
4260
+ // ── the pager (#35) ────────────────────────────────────────────────
4261
+
4262
+ _usesPager070() {
4263
+ const c = this.config;
4264
+ return !!c.dataSource || !!c.pagerLabels || c.maxJumpOffset != null
4265
+ || (typeof c.onPageChange === 'function' && c.totalCount == null && typeof c.hasMore === 'boolean');
4266
+ }
4267
+
4268
+ _pagerLabel(name) {
4269
+ const labels = this.config.pagerLabels || {};
4270
+ return labels[name];
4271
+ }
4272
+
4273
+ _pagerRefusalTooDeep() {
4274
+ const max = this.config.maxJumpOffset;
4275
+ const fn = this._pagerLabel('tooDeep');
4276
+ if (typeof fn === 'function') return String(fn(max));
4277
+ if (typeof fn === 'string') return fn;
4278
+ return `Past ${Number(max).toLocaleString()} rows. Narrow the filters first.`;
4279
+ }
4280
+
4281
+ _pagerButton(icon, name, tip, disabled, onClick, refusal = null) {
4282
+ const label = this._pagerLabel(name);
4283
+ const btn = this._createPaginationBtn(icon, typeof label === 'string' ? label : tip,
4284
+ disabled || !!refusal, onClick);
4285
+ btn.dataset.pager = name;
4286
+ if (typeof label === 'string' && label) {
4287
+ const span = document.createElement('span');
4288
+ span.className = 'twm-pagination-btn__label';
4289
+ span.textContent = label;
4290
+ btn.appendChild(span);
4291
+ btn.classList.add('twm-pagination-btn--labelled');
4292
+ btn.style.padding = '1px 6px 1px 3px';
4293
+ // NEVER NARROWER THAN ITS WORD. The pager's buttons carry a
4294
+ // `min-width` sized for a glyph (22px, `!important` in the compact
4295
+ // skin), and in a narrow tile the flex row shrank a labelled one
4296
+ // to it and clipped its word. Inline and `important`, because it
4297
+ // is the one declaration that must beat both skins.
4298
+ btn.style.setProperty('min-width', 'max-content', 'important');
4299
+ btn.style.flexShrink = '0';
4300
+ }
4301
+ if (refusal) {
4302
+ // The reason is ON the control, where a person looks and a test can
4303
+ // read it — not only in a tooltip.
4304
+ btn.dataset.refusal = refusal;
4305
+ btn.setAttribute('data-tooltip', refusal);
4306
+ btn.title = refusal;
4307
+ }
4308
+ return btn;
4309
+ }
4310
+
4311
+ /**
4312
+ * THE PAGE ARITHMETIC, ONCE — read by the pager above the rows (0.7.0) and
4313
+ * by the `paging` strip under them (0.6.2), which each worked it out for
4314
+ * themselves. `held` is the rows drawn on this page; `total` a number or
4315
+ * null (unknown, when `hasMore` says whether there is a next page).
4316
+ *
4317
+ * @returns {{known: boolean, hasPrev: boolean, hasNext: boolean, page: number,
4318
+ * pages: number|null, lastOffset: number|null, lastRefusal: string|null}}
4319
+ */
4320
+ _pageNav({ offset = 0, held = 0, total = null, hasMore = false, pageSize }) {
4321
+ const size = Math.max(1, pageSize | 0);
4322
+ const known = typeof total === 'number' && Number.isFinite(total);
4323
+ const lastOffset = known ? Math.floor(Math.max(0, total - 1) / size) * size : null;
4324
+ const maxJump = Number.isFinite(this.config.maxJumpOffset) ? this.config.maxJumpOffset : null;
4325
+ let lastRefusal = null;
4326
+ if (!known) {
4327
+ const words = this._pagerLabel('noLast');
4328
+ lastRefusal = typeof words === 'string' ? words : 'The total is not known, so there is no last page.';
4329
+ } else if (maxJump !== null && lastOffset > maxJump) {
4330
+ lastRefusal = this._pagerRefusalTooDeep();
4331
+ }
4332
+ return {
4333
+ known,
4334
+ hasPrev: offset > 0,
4335
+ hasNext: known ? offset + held < total : !!hasMore,
4336
+ page: Math.floor(offset / size) + 1,
4337
+ pages: known ? Math.max(1, Math.ceil(total / size)) : null,
4338
+ lastOffset,
4339
+ lastRefusal,
4340
+ };
4341
+ }
4342
+
4343
+ /**
4344
+ * The pager for any mode, with the two things the 0.6 strip could not do:
4345
+ * an UNKNOWN total (no "of N", no last page, Next until a short page) and
4346
+ * a REFUSED deep jump (`maxJumpOffset`). For a known total its words are
4347
+ * the 0.6 strip's.
4348
+ */
4349
+ _createPager070() {
4350
+ const c = this.config;
4351
+ const { rows, pageSize } = c;
4352
+ const source = !!c.dataSource;
4353
+ const server = !source && typeof c.onPageChange === 'function';
4354
+ const src = this._source || { offset: 0, total: null, hasMore: false };
4355
+ const offset = source ? src.offset : server ? (c.offset || 0) : this._state.offset;
4356
+ let total;
4357
+ let hasMoreKnown;
4358
+ if (source) {
4359
+ total = src.total;
4360
+ hasMoreKnown = src.hasMore;
4361
+ } else if (server) {
4362
+ const unknown = c.totalCount == null && typeof c.hasMore === 'boolean';
4363
+ total = unknown ? null : (c.totalCount || rows.length);
4364
+ hasMoreKnown = unknown ? c.hasMore : null;
4365
+ } else {
4366
+ total = this._getProcessedRows().length;
4367
+ hasMoreKnown = null;
4368
+ }
4369
+ const known = typeof total === 'number' && Number.isFinite(total);
4370
+ const isFiltered = !source && !server && this._state.filters.size > 0;
4371
+ const unfilteredTotal = !source && !server ? rows.length : total;
4372
+ const drawn = source || server ? rows.length : Math.max(0, Math.min(pageSize, total - offset));
4373
+ const pn = this._pageNav({ offset, held: drawn, total, hasMore: hasMoreKnown, pageSize });
4374
+ const { hasPrev, hasNext, page } = pn;
4375
+ const totalPages = pn.pages;
4376
+ if (known && totalPages <= 1 && !isFiltered && !hasPrev) return null;
4377
+ if (!known && !hasPrev && !hasNext) return null;
4378
+ const maxJump = Number.isFinite(c.maxJumpOffset) ? c.maxJumpOffset : null;
4379
+
4380
+ const go = (newOffset) => {
4381
+ if (source) { this._sourceGoTo(newOffset); return; }
4382
+ if (server) { this._awaitWithSpinner(c.onPageChange(newOffset, pageSize)); return; }
4383
+ this._state.offset = newOffset;
4384
+ this.render();
4385
+ };
4386
+
4387
+ const el = document.createElement('div');
4388
+ el.className = 'twm-pagination-controls';
4389
+ if (!known) el.classList.add('twm-pagination-controls--unknown-total');
4390
+ el.style.cssText = 'display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;';
4391
+
4392
+ const info = document.createElement('span');
4393
+ info.className = 'twm-pagination-info';
4394
+ info.style.cssText = 'color:#aaa; white-space:nowrap;';
4395
+ const from = (offset + 1).toLocaleString();
4396
+ const to = (offset + drawn).toLocaleString();
4397
+ if (drawn === 0) {
4398
+ info.textContent = hasPrev ? `Page ${page} · no rows`
4399
+ : isFiltered ? `0 of ${unfilteredTotal.toLocaleString()} rows match` : 'No rows';
4400
+ } else if (!known) {
4401
+ info.textContent = `Rows ${from}–${to}`;
4402
+ } else if (isFiltered) {
4403
+ info.textContent = `Rows ${from}–${to} of ${total.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
4404
+ } else {
4405
+ info.textContent = `Rows ${from}–${to} of ${total.toLocaleString()}`;
4406
+ }
4407
+
4408
+ const spacer = document.createElement('div');
4409
+ spacer.style.flex = '1';
4410
+
4411
+ const firstBtn = this._pagerButton('first_page', 'first', 'First page', !hasPrev, () => go(0));
4412
+ const prevBtn = this._pagerButton('chevron_left', 'prev', 'Previous page', !hasPrev,
4413
+ () => go(Math.max(0, offset - pageSize)));
4414
+ const pageInfo = document.createElement('span');
4415
+ pageInfo.className = 'twm-pagination-page';
4416
+ pageInfo.style.cssText = 'color:#aaa; font-size:10px; min-width:72px; text-align:center;';
4417
+ pageInfo.textContent = known ? `Page ${page} of ${totalPages.toLocaleString()}` : `Page ${page}`;
4418
+ const nextBtn = this._pagerButton('chevron_right', 'next', 'Next page', !hasNext,
4419
+ () => go(offset + pageSize));
4420
+ const { lastOffset, lastRefusal } = pn;
4421
+ const lastBtn = this._pagerButton('last_page', 'last', 'Last page',
4422
+ !hasNext || lastOffset === offset, () => {
4423
+ if (lastOffset !== null) go(lastOffset);
4424
+ }, lastRefusal);
4425
+
4426
+ const jumpInput = document.createElement('input');
4427
+ jumpInput.type = 'number';
4428
+ jumpInput.className = 'twm-pagination-input';
4429
+ jumpInput.style.cssText = 'width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;';
4430
+ jumpInput.min = '1';
4431
+ if (known) jumpInput.max = String(total);
4432
+ jumpInput.placeholder = 'Row #';
4433
+ const jumpBtn = this._createPaginationBtn(null, 'Go to row', false, () => {
4434
+ const target = Math.floor(Number(jumpInput.value));
4435
+ if (!Number.isFinite(target) || target < 1) return;
4436
+ const row = known ? Math.min(target, Math.max(1, total)) : target;
4437
+ const newOffset = Math.floor((row - 1) / pageSize) * pageSize;
4438
+ if (maxJump !== null && newOffset > maxJump) {
4439
+ // Refused, and says so where the person is looking.
4440
+ const why = this._pagerRefusalTooDeep();
4441
+ jumpInput.setAttribute('aria-invalid', 'true');
4442
+ jumpInput.title = why;
4443
+ jumpInput.dataset.refusal = why;
4444
+ return;
4445
+ }
4446
+ jumpInput.removeAttribute('aria-invalid');
4447
+ delete jumpInput.dataset.refusal;
4448
+ if (server && typeof c.onJumpToRow === 'function') { c.onJumpToRow(row - 1); return; }
4449
+ go(newOffset);
4450
+ }, 'Go');
4451
+ jumpBtn.dataset.pager = 'go';
4452
+
4453
+ el.append(info, spacer, firstBtn, prevBtn, pageInfo, nextBtn, lastBtn, jumpInput, jumpBtn);
4454
+ if (c.showExportButton) {
4455
+ el.appendChild(this._createPaginationBtn('download', 'Download visible rows as CSV', false,
4456
+ () => this.downloadCSV('data.csv')));
4457
+ }
4458
+ return el;
4459
+ }
4460
+
4461
+ // ─────────────────────────────────────────────────────────────────
4462
+ // 0.7.0 — filter controls that are not text boxes (#37)
4463
+ // ─────────────────────────────────────────────────────────────────
4464
+
4465
+ /** The spec for a column, normalised: null for the 0.4 text box. */
4466
+ _filterKindFor(colIdx, header) {
4467
+ const fk = this.config.filterKinds;
4468
+ if (fk == null) return null;
4469
+ let spec;
4470
+ if (typeof fk === 'function') {
4471
+ try { spec = fk(colIdx, header); } catch (err) {
4472
+ console.error('[DataTable] filterKinds threw', err);
4473
+ spec = null;
4474
+ }
4475
+ } else {
4476
+ spec = perColumn(fk, colIdx);
4477
+ }
4478
+ if (spec == null || spec === 'text' || spec === false) return null;
4479
+ if (typeof spec === 'function') return { kind: 'custom', create: spec };
4480
+ if (typeof spec === 'object') {
4481
+ if (spec.kind === 'text') return null;
4482
+ if (spec.kind === 'chips') return spec;
4483
+ if (typeof spec.create === 'function') return { ...spec, kind: spec.kind || 'custom' };
4484
+ }
4485
+ if (spec === 'chips') return null; // chips need options
4486
+ return null;
4487
+ }
4488
+
4489
+ /** The column's control — built once, then the same element every render. */
4490
+ _filterControlFor(colIdx, header) {
4491
+ const spec = this._filterKindFor(colIdx, header);
4492
+ if (!spec) return null;
4493
+ const have = this._filterControls.get(colIdx);
4494
+ if (have && have.kind === spec.kind && have.specKey === (spec.options || spec.create)) {
4495
+ return have.control;
4496
+ }
4497
+ if (have) this._destroyOneControl(have.control);
4498
+ const value = this._state.filters.get(colIdx) ?? null;
4499
+ const ctx = {
4500
+ column: colIdx,
4501
+ header,
4502
+ value,
4503
+ counts: this._filterCounts.get(colIdx) ?? null,
4504
+ table: this,
4505
+ setValue: (v) => this.setFilter(colIdx, v),
4506
+ };
4507
+ let control = null;
4508
+ try {
4509
+ control = spec.kind === 'chips' ? createChipsFilter(ctx, spec) : spec.create(ctx);
4510
+ } catch (err) {
4511
+ console.error('[DataTable] filter control threw while being built', err);
4512
+ control = null;
4513
+ }
4514
+ if (typeof Node !== 'undefined' && control instanceof Node) control = { el: control };
4515
+ if (!control || !control.el) return null;
4516
+ control.el.classList?.add('twm-dt-filter-control');
4517
+ this._filterControls.set(colIdx, {
4518
+ kind: spec.kind, specKey: spec.options || spec.create, control,
4519
+ match: typeof control.match === 'function' ? control.match
4520
+ : spec.kind === 'chips' ? chipsMatch(spec) : null,
4521
+ });
4522
+ return control;
4523
+ }
4524
+
4525
+ /** The client-side rule for a column with a control, or null. */
4526
+ _filterMatcher(colIdx) {
4527
+ if (!this._filterControls.size && this.config.filterKinds == null) return null;
4528
+ const entry = this._filterControls.get(colIdx);
4529
+ if (entry) return entry.match || defaultControlMatch;
4530
+ // A control not yet built (no render yet, a restore): the spec decides.
4531
+ const spec = this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
4532
+ if (!spec) return null;
4533
+ return spec.kind === 'chips' ? chipsMatch(spec) : (spec.match || defaultControlMatch);
4534
+ }
4535
+
4536
+ _isEmptyFilterValue(v) {
4537
+ if (v == null || v === '' || v === false) return true;
4538
+ if (Array.isArray(v)) return v.length === 0;
4539
+ if (typeof v === 'object' && !(v instanceof Date)) {
4540
+ return Object.values(v).every((x) => x == null || x === '');
4541
+ }
4542
+ return false;
4543
+ }
4544
+
4545
+ _destroyOneControl(control) {
4546
+ try { control?.destroy?.(); } catch (err) { console.error('[DataTable] filter control destroy threw', err); }
4547
+ control?.el?.remove?.();
4548
+ }
4549
+
4550
+ _destroyFilterControls() {
4551
+ for (const { control } of this._filterControls.values()) this._destroyOneControl(control);
4552
+ this._filterControls.clear();
4553
+ }
4554
+
4555
+ _notifyFilterChange() {
4556
+ if (typeof this.config.onFilterChange !== 'function') return;
4557
+ try { this.config.onFilterChange(this._filterList()); } catch (err) {
4558
+ console.error('[DataTable] onFilterChange threw', err);
4559
+ }
4560
+ }
4561
+
4562
+ /** The column filters in force: `[{column, header, value}]`. */
4563
+ getFilters() {
4564
+ return this._filterList();
4565
+ }
4566
+
4567
+ /**
4568
+ * Set one column's filter (`null`, `''` or an empty array clears it). The
4569
+ * value is whatever the column's control works in — text for the 0.4 box.
4570
+ */
4571
+ setFilter(colIdx, value) {
4572
+ const textBox = !this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
4573
+ const v = textBox && value != null && typeof value !== 'string' ? String(value) : value;
4574
+ if (textBox) {
4575
+ const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
4576
+ const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
4577
+ const input = cell?.querySelector('.twm-data-table__filter-input');
4578
+ if (input) input.value = v || '';
4579
+ }
4580
+ this._onFilterInput(colIdx, v ?? '');
4581
+ }
4582
+
4583
+ /**
4584
+ * Counts for a column's filter control — chips draw them beside each
4585
+ * option (keyed by the option's value; `''` is "no filter"), a dimmed
4586
+ * chip at zero. `setFilterCounts(colIdx, counts)`, or
4587
+ * `setFilterCounts({[colIdx]: counts})` for several. They are kept, so a
4588
+ * control built later starts with them.
4589
+ */
4590
+ setFilterCounts(colIdx, counts) {
4591
+ if (colIdx && typeof colIdx === 'object' && counts === undefined) {
4592
+ for (const [k, c] of Object.entries(colIdx)) this.setFilterCounts(Number(k), c);
4593
+ return;
4594
+ }
4595
+ const col = Number(colIdx);
4596
+ if (!Number.isInteger(col)) return;
4597
+ if (counts == null) this._filterCounts.delete(col); else this._filterCounts.set(col, counts);
4598
+ const entry = this._filterControls.get(col);
4599
+ if (entry) {
4600
+ try { entry.control.setCounts?.(counts ?? null); } catch (err) {
4601
+ console.error('[DataTable] filter control setCounts threw', err);
4602
+ }
4603
+ }
4604
+ }
4605
+
4606
+ /** What `setFilterCounts` last gave a column, or null. */
4607
+ getFilterCounts(colIdx) {
4608
+ return this._filterCounts.get(Number(colIdx)) ?? null;
4609
+ }
4610
+
4611
+ // ── focus across a render ──────────────────────────────────────────
4612
+
4613
+ _captureFocus070(doc) {
4614
+ const active = doc?.activeElement;
4615
+ if (!active || active === doc.body) return null;
4616
+ for (const [colIdx, { control }] of this._filterControls) {
4617
+ if (control.el?.contains?.(active)) {
4618
+ let sel = null;
4619
+ try {
4620
+ if (typeof active.selectionStart === 'number') sel = [active.selectionStart, active.selectionEnd];
4621
+ } catch (_) { /* not a text field */ }
4622
+ return { kind: 'control', colIdx, el: active, sel };
4623
+ }
4624
+ }
4625
+ // Inside an open detail (a button in it, a field): the detail is drawn
4626
+ // again by the render, so the same place in it — by its path under the
4627
+ // detail's host — gets the focus back (the release review).
4628
+ const detailHost = active.closest?.('.twm-dt-detail');
4629
+ if (detailHost && this._tbodyEl?.contains(detailHost)) {
4630
+ const tr = detailHost.closest('tr')?.__detailOf;
4631
+ if (tr && tr.__rowIndex !== undefined) {
4632
+ const path = [];
4633
+ for (let n = active; n && n !== detailHost; n = n.parentElement) {
4634
+ path.unshift(Array.prototype.indexOf.call(n.parentElement.children, n));
4635
+ }
4636
+ let sel = null;
4637
+ try {
4638
+ if (typeof active.selectionStart === 'number') sel = [active.selectionStart, active.selectionEnd];
4639
+ } catch (_) { /* not a text field */ }
4640
+ return { kind: 'detail', key: this._rowKey(tr.__row, tr.__rowIndex), path, tag: active.tagName, sel };
4641
+ }
4642
+ }
4643
+ const toggle = active.closest?.('.twm-dt-detail-toggle');
4644
+ if (toggle && this._tbodyEl?.contains(toggle)) {
4645
+ const tr = toggle.closest('tr');
4646
+ if (tr && tr.__rowIndex !== undefined) {
4647
+ return { kind: 'toggle', key: this._rowKey(tr.__row, tr.__rowIndex) };
4648
+ }
4649
+ }
4650
+ return null;
4651
+ }
4652
+
4653
+ _restoreFocus070(f) {
4654
+ try {
4655
+ if (f.kind === 'control') {
4656
+ if (!f.el.isConnected) return;
4657
+ f.el.focus({ preventScroll: true });
4658
+ if (f.sel) f.el.setSelectionRange?.(f.sel[0], f.sel[1]);
4659
+ } else if (f.kind === 'detail' && f.key !== null) {
4660
+ let n = this._rowElByKey(f.key)?.__detail?.[0]?.querySelector('.twm-dt-detail');
4661
+ for (const i of f.path) n = n?.children[i];
4662
+ if (n && n.tagName === f.tag) {
4663
+ n.focus({ preventScroll: true });
4664
+ if (f.sel) n.setSelectionRange?.(f.sel[0], f.sel[1]);
4665
+ }
4666
+ } else if (f.kind === 'toggle' && f.key !== null) {
4667
+ const tr = this._rowElByKey(f.key);
4668
+ tr?.querySelector(':scope > td > .twm-dt-detail-toggle')?.focus({ preventScroll: true });
4669
+ }
4670
+ } catch (_) { /* detached */ }
4671
+ }
4672
+
4673
+ /** The one row spanning every column that says there are no rows (or why
4674
+ * they could not be read). Under `dataSource` the plain message is drawn
4675
+ * by class, so it is quiet while the first answer is on its way. */
4676
+ _buildEmptyRow(colCount, emptyMessage) {
4677
+ const emptyTr = document.createElement('tr');
4678
+ emptyTr.className = 'data-preview-row data-table__empty-row';
4679
+ const emptyTd = document.createElement('td');
4680
+ emptyTd.colSpan = colCount;
4681
+ if (this._error) {
4682
+ // 0.5.0. Nothing to keep: the failure takes the empty row.
4683
+ emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--error';
4684
+ emptyTd.setAttribute('role', 'alert');
4685
+ this._appendStateContent(emptyTd, this._errorContent());
4686
+ } else if (this.config.emptyState !== undefined) {
4687
+ // 0.5.0. The consumer's own words (or node), styled by class
4688
+ // rather than inline, and quiet while the table is loading.
4689
+ emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
4690
+ const state = typeof this.config.emptyState === 'function'
4691
+ ? this.config.emptyState() : this.config.emptyState;
4692
+ this._appendStateContent(emptyTd, state);
4693
+ } else if (this.config.dataSource) {
4694
+ emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
4695
+ emptyTd.textContent = emptyMessage;
4696
+ } else {
4697
+ emptyTd.style.cssText
4698
+ = 'text-align:center; font-style:italic; color:#888; padding:16px;';
4699
+ emptyTd.textContent = emptyMessage;
4700
+ }
4701
+ emptyTr.appendChild(emptyTd);
4702
+ return emptyTr;
4703
+ }
4704
+
3183
4705
  // ─────────────────────────────────────────────────────────────────
3184
4706
  // 0.5.0 — decoration, states and the typed columns
3185
4707
  // ─────────────────────────────────────────────────────────────────
@@ -3412,6 +4934,10 @@ export class DataTable {
3412
4934
  tr.__rowKey = this._rowKey(row, rowIdx);
3413
4935
  }
3414
4936
  this._paintActive(tr);
4937
+ // 0.7.0 `follow`/`refresh`: a row the last answer did not have.
4938
+ if (this._newKeys) {
4939
+ tr.classList.toggle('twm-dt-row--new', this._newKeys.has(this._rowKey(row, rowIdx)));
4940
+ }
3415
4941
  }
3416
4942
 
3417
4943
  /** The open mark on one row, from `_activeKey`. */
@@ -3616,6 +5142,30 @@ export class DataTable {
3616
5142
  : 0;
3617
5143
  natural[i] = Math.max(body, head);
3618
5144
  }
5145
+ // 0.7.0 `filterKinds`: A FILTER CONTROL IS IN THE MEASURE. The text
5146
+ // boxes stay out (an `<input>`'s default width would set every
5147
+ // column), but a control — the Level chips with their counts — is
5148
+ // drawn at its NARROWEST (`min-content`: the widest chip) for this
5149
+ // reflow, so the label row's width above already spans it, and that
5150
+ // narrowest width is kept as the column's FLOOR for the fit pass,
5151
+ // which would otherwise squeeze the column under one chip in a narrow
5152
+ // tile and cut it off. Above the floor the cell wraps its chips.
5153
+ const floors = new Array(cols).fill(0);
5154
+ const filterRow = headerTable.querySelector('thead > tr.twm-data-table__filter-row--controls');
5155
+ if (filterRow) {
5156
+ for (let i = 0; i < cols && i < filterRow.children.length; i++) {
5157
+ const th = filterRow.children[i];
5158
+ if (!th.classList.contains('data-table__filter-cell--control')) continue;
5159
+ const el = th.firstElementChild;
5160
+ if (!el) continue;
5161
+ const cs = getComputedStyle(th);
5162
+ const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)
5163
+ + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
5164
+ floors[i] = Math.ceil(el.getBoundingClientRect().width / zoom + pad);
5165
+ natural[i] = Math.max(natural[i], floors[i]);
5166
+ }
5167
+ }
5168
+ this._filterFloors = floors;
3619
5169
 
3620
5170
  for (const tr of sampled) tr.remove();
3621
5171
  headerTable.classList.remove('twm-dt-measuring');
@@ -3804,6 +5354,7 @@ export class DataTable {
3804
5354
  firstIdx,
3805
5355
  overrides: this._colWidths,
3806
5356
  fit: this.config.columnFit,
5357
+ floors: this._filterFloors,
3807
5358
  });
3808
5359
  const total = widths.reduce((a, b) => a + b, 0);
3809
5360
 
@@ -3913,6 +5464,11 @@ export class DataTable {
3913
5464
  const n = natural.length;
3914
5465
  const firstIdx = opts.firstIdx ?? 0;
3915
5466
  const overrides = opts.overrides || {};
5467
+ // 0.7.0: a column's own floor (its filter control's narrowest width,
5468
+ // `_measureNaturalWidths`) — never squeezed below it, even when the
5469
+ // table then scrolls sideways.
5470
+ const floors = Array.isArray(opts.floors) ? opts.floors : [];
5471
+ const floorOf = (i) => Math.max(FLOOR, Math.ceil(floors[i] || 0));
3916
5472
  // Anything that is not the literal opt-in is the historical behaviour.
3917
5473
  // Read once, so an embedder that passes a typo gets the old layout
3918
5474
  // rather than half of each.
@@ -3993,7 +5549,7 @@ export class DataTable {
3993
5549
  const budget = avail - protectedSum;
3994
5550
  if (budget < shrinkable.length * FLOOR) {
3995
5551
  // Even at the floor we overflow → let the body scroll sideways.
3996
- for (const i of shrinkable) widths[i] = FLOOR;
5552
+ for (const i of shrinkable) widths[i] = floorOf(i);
3997
5553
  return widths;
3998
5554
  }
3999
5555
  // Max-min fair allocation: the narrow columns keep their content,
@@ -4010,7 +5566,7 @@ export class DataTable {
4010
5566
  } else {
4011
5567
  const level = Math.max(FLOOR, fair);
4012
5568
  for (let j = k; j < shrinkable.length; j++) {
4013
- widths[shrinkable[j]] = level;
5569
+ widths[shrinkable[j]] = Math.max(level, floorOf(shrinkable[j]));
4014
5570
  }
4015
5571
  // Dump any rounding remainder onto the widest column.
4016
5572
  const leftover = remaining - colsLeft * level;
@@ -4296,6 +5852,9 @@ export class DataTable {
4296
5852
  : bodyTable.querySelectorAll('tbody td');
4297
5853
  cells.forEach((td) => {
4298
5854
  if (td.parentElement?.classList.contains('twm-dt-spacer')) return;
5855
+ // 0.7.0: a row's detail — and anything drawn inside it — is not a
5856
+ // clipped cell of the table.
5857
+ if (td.closest?.('.twm-dt-detail-row')) return;
4299
5858
  const clipped = td.scrollWidth > td.clientWidth + 1;
4300
5859
  if (clipped) {
4301
5860
  if (!td.title) td.title = td.textContent;
@@ -4360,6 +5919,25 @@ export class DataTable {
4360
5919
  this._installActivation(tbody, table);
4361
5920
  }
4362
5921
 
5922
+ // 0.7.0 `rowDetail` from the keyboard: on the focused table (or a row's
5923
+ // toggle), ArrowRight opens the current row's detail and ArrowLeft
5924
+ // closes it — the tree-grid keys. The toggle itself is a button, so
5925
+ // Enter and Space work on it as on any button.
5926
+ if (this._detailOn()) {
5927
+ const onDetailKey = (ev) => {
5928
+ if (ev.key !== 'ArrowRight' && ev.key !== 'ArrowLeft') return;
5929
+ if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) return;
5930
+ const toggle = ev.target?.closest?.('.twm-dt-detail-toggle');
5931
+ let tr = null;
5932
+ if (toggle && table.contains(toggle)) tr = toggle.closest('tr');
5933
+ else if (ev.target === table) tr = this._currentRowEl();
5934
+ if (!tr || tr.__rowIndex === undefined) return;
5935
+ if (this._setRowElExpanded(tr, ev.key === 'ArrowRight', ev)) ev.preventDefault();
5936
+ };
5937
+ table.addEventListener('keydown', onDetailKey);
5938
+ this._disposers.push(() => table.removeEventListener('keydown', onDetailKey));
5939
+ }
5940
+
4363
5941
  if (copyable) {
4364
5942
  const handleContextMenu = (event) => {
4365
5943
  const rowEl = event.target.closest('tr');
@@ -4367,6 +5945,12 @@ export class DataTable {
4367
5945
  this._hideContextMenu();
4368
5946
  return;
4369
5947
  }
5948
+ // 0.7.0: inside a row's detail, the page's own menu (copy the
5949
+ // text a person selected there), not the table's.
5950
+ if (rowEl.closest('.twm-dt-detail-row')) {
5951
+ this._hideContextMenu();
5952
+ return;
5953
+ }
4370
5954
 
4371
5955
  event.preventDefault();
4372
5956
  event.stopPropagation();