flexdesk 0.6.2 → 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;
@@ -1088,7 +1256,12 @@ export class DataTable {
1088
1256
  // body's auto layout, then we lock both tables to those widths
1089
1257
  // via table-layout:fixed + explicit cell widths.
1090
1258
  if (this._headerTableEl && this._tableEl) {
1091
- requestAnimationFrame(() => this._syncHeaderWidths());
1259
+ // 0.6.3: and once the widths are fixed the rows may be another
1260
+ // height than they were measured at — so the window measures again.
1261
+ requestAnimationFrame(() => {
1262
+ this._syncHeaderWidths();
1263
+ this._virtRemeasure();
1264
+ });
1092
1265
  // Re-sync on container resize so columns stay aligned when
1093
1266
  // the parent flex / grid layout changes (window resize,
1094
1267
  // splitter drag, etc.).
@@ -1114,7 +1287,8 @@ export class DataTable {
1114
1287
 
1115
1288
  // Install interactions (readonly only prevents editing, not selection/copy)
1116
1289
  if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
1117
- || typeof this.config.onRowActivate === 'function')) {
1290
+ || typeof this.config.onRowActivate === 'function'
1291
+ || typeof this.config.rowDetail === 'function')) {
1118
1292
  this._installInteractions();
1119
1293
  }
1120
1294
 
@@ -1125,6 +1299,25 @@ export class DataTable {
1125
1299
  else if (hadTableFocus && this._tableEl) {
1126
1300
  try { this._tableEl.focus({ preventScroll: true }); } catch (_) { /* detached */ }
1127
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
+ }
1128
1321
  }
1129
1322
 
1130
1323
  /**
@@ -1252,6 +1445,19 @@ export class DataTable {
1252
1445
  _teardown(emptyHost) {
1253
1446
  autoDisposeUnwatch(this);
1254
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();
1255
1461
  this._runOffs(this._activationOffs);
1256
1462
  this._activationOffs = [];
1257
1463
  this._cleanup();
@@ -1290,7 +1496,7 @@ export class DataTable {
1290
1496
  _getProcessedRows() {
1291
1497
  if (this._processedRows !== null) return this._processedRows;
1292
1498
 
1293
- const isServerSide = typeof this.config.onPageChange === 'function';
1499
+ const isServerSide = this._isServerSide();
1294
1500
  let rows = this.config.rows;
1295
1501
  let indexMap = rows.map((_, i) => i);
1296
1502
 
@@ -1317,6 +1523,9 @@ export class DataTable {
1317
1523
  const filters = this._state.filters;
1318
1524
  const filteredRows = [];
1319
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));
1320
1529
 
1321
1530
  for (let i = 0; i < rows.length; i++) {
1322
1531
  const row = rows[i];
@@ -1325,6 +1534,14 @@ export class DataTable {
1325
1534
  for (const [colIdx, filterText] of filters) {
1326
1535
  if (!filterText) continue;
1327
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
+ }
1328
1545
  const colType = this._valueKind(colIdx);
1329
1546
 
1330
1547
  if (colType === 'num') {
@@ -1515,6 +1732,20 @@ export class DataTable {
1515
1732
  const th = document.createElement('th');
1516
1733
  th.className = 'data-table__filter-cell';
1517
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
+
1518
1749
  const wrapper = document.createElement('div');
1519
1750
  wrapper.className = 'twm-data-table__filter-wrapper';
1520
1751
 
@@ -1863,17 +2094,25 @@ export class DataTable {
1863
2094
  if (this._filterDebounceTimer) {
1864
2095
  clearTimeout(this._filterDebounceTimer);
1865
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 };
1866
2100
  this._filterDebounceTimer = setTimeout(() => {
2101
+ this._pendingFilter = null;
1867
2102
  this._onFilterInput(colIdx, value);
1868
2103
  }, 200);
1869
2104
  }
1870
2105
 
1871
2106
  _onFilterInput(colIdx, value) {
1872
- 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) {
1873
2111
  this._state.filters.set(colIdx, value);
1874
2112
  } else {
1875
2113
  this._state.filters.delete(colIdx);
1876
2114
  }
2115
+ this._pendingFilter = null;
1877
2116
 
1878
2117
  this._invalidateProcessedCache();
1879
2118
  this._state.offset = 0;
@@ -1881,9 +2120,40 @@ export class DataTable {
1881
2120
  this._state.anchorIndex = null;
1882
2121
 
1883
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
+ }
1884
2138
  this.render();
1885
2139
  }
1886
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
+
1887
2157
  _getActiveFilterColIdx() {
1888
2158
  const active = document.activeElement;
1889
2159
  if (!active || !active.classList.contains('twm-data-table__filter-input')) return null;
@@ -1987,6 +2257,10 @@ export class DataTable {
1987
2257
  }
1988
2258
 
1989
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();
1990
2264
  const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
1991
2265
 
1992
2266
  // Server-side pagination uses config offset, client-side uses state offset
@@ -2147,6 +2421,33 @@ export class DataTable {
2147
2421
  if (!p || typeof p !== 'object') return null;
2148
2422
  const size = Number.isFinite(p.pageSize) && p.pageSize >= 1
2149
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
+ }
2150
2451
  return {
2151
2452
  ...p,
2152
2453
  pageSize: size,
@@ -2181,7 +2482,15 @@ export class DataTable {
2181
2482
  */
2182
2483
  setPaging(patch = {}) {
2183
2484
  if (!this.config.paging) return;
2485
+ const before = this.config.dataSource ? this.pagingState?.mode : null;
2184
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
+ }
2185
2494
  this._refreshPagingStrip();
2186
2495
  this._pagingMaybeLoadMore();
2187
2496
  }
@@ -2237,6 +2546,9 @@ export class DataTable {
2237
2546
  _pagingMaybeLoadMore() {
2238
2547
  const p = this._pagingConfig();
2239
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;
2240
2552
  const state = this.pagingState;
2241
2553
  if (state.mode !== 'scroll' || p.held >= state.hardLimit) return;
2242
2554
  const wrap = this._tableWrapEl;
@@ -2279,10 +2591,10 @@ export class DataTable {
2279
2591
  else if (known > to) of = ` of more than ${n(known - 1)}`;
2280
2592
  info.textContent = held ? `Rows ${n(from)}–${n(to)}${of}` : 'No rows on this page';
2281
2593
 
2282
- const hasPrev = p.offset > 0;
2283
- const hasNext = exact ? p.offset + held < total : p.hasMore;
2284
- const lastOffset = exact && total > 0
2285
- ? 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;
2286
2598
  // A disabled button's click is ignored here too: a browser sends none,
2287
2599
  // but a synthetic one (a test, a script) still reaches the listener.
2288
2600
  const go = (offset) => (ev) => {
@@ -2291,26 +2603,36 @@ export class DataTable {
2291
2603
  const nav = document.createElement('span');
2292
2604
  nav.className = 'twm-dt-paging__nav';
2293
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));
2294
2614
  nav.append(
2295
- this._createPaginationBtn('first_page', 'First page', off || !hasPrev, go(0)),
2296
- 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,
2297
2617
  go(Math.max(0, p.offset - pageSize))),
2298
2618
  );
2299
2619
  const page = document.createElement('span');
2300
2620
  page.className = 'twm-dt-paging__page';
2301
- const current = Math.floor(p.offset / pageSize) + 1;
2302
2621
  page.textContent = exact
2303
- ? `Page ${n(current)} of ${n(Math.max(1, Math.ceil(total / pageSize)))}`
2304
- : `Page ${n(current)}`;
2622
+ ? `Page ${n(pn.page)} of ${n(pn.pages)}`
2623
+ : `Page ${n(pn.page)}`;
2305
2624
  nav.append(
2306
2625
  page,
2307
- this._createPaginationBtn('chevron_right', 'Next page', off || !hasNext,
2626
+ button('chevron_right', 'next', 'Next page', off || !hasNext,
2308
2627
  go(p.offset + pageSize)),
2309
2628
  // The last page needs the exact count: without it there is no
2310
2629
  // offset to go to, and the button says so by being off.
2311
- this._createPaginationBtn('last_page',
2312
- lastOffset === null ? 'Last page (the total is not known)' : 'Last page',
2313
- 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)),
2314
2636
  );
2315
2637
  el.appendChild(nav);
2316
2638
  } else {
@@ -2372,12 +2694,17 @@ export class DataTable {
2372
2694
  }
2373
2695
 
2374
2696
  _createTable() {
2375
- 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;
2376
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;
2377
2704
 
2378
2705
  // Server-side pagination: rows already represent current page, use config offset for row numbering
2379
2706
  // Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
2380
- const isServerSide = typeof onPageChange === 'function';
2707
+ const isServerSide = this._isServerSide();
2381
2708
  const processedRows = isServerSide ? rows : this._getProcessedRows();
2382
2709
  const offset = isServerSide ? (configOffset || 0) : this._state.offset;
2383
2710
  // NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
@@ -2482,11 +2809,13 @@ export class DataTable {
2482
2809
 
2483
2810
  // 0.5.0 `showRowNumbers: 'position'`: where the first row drawn here
2484
2811
  // stands in the order SHOWN (the page's first row on a later page).
2485
- this._positionBase = isServerSide ? (configOffset || 0)
2812
+ this._positionBase = this.config.dataSource ? (this._source?.offset || 0)
2813
+ : isServerSide ? (configOffset || 0)
2486
2814
  : (this.config.pagination ? offset : 0);
2487
2815
  // `paging`: a page's rows stand at its offset in the SOURCE, which only
2488
- // the numbering reads — their indexes stay those of `rows`.
2489
- 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();
2490
2819
  if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
2491
2820
 
2492
2821
  // Body
@@ -2500,7 +2829,10 @@ export class DataTable {
2500
2829
  });
2501
2830
  } else {
2502
2831
  pageRows.forEach((row, localIdx) => {
2503
- 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);
2504
2836
  });
2505
2837
  }
2506
2838
 
@@ -2664,6 +2996,9 @@ export class DataTable {
2664
2996
  const offsets = new Float64Array(n + 1);
2665
2997
  const fn = typeof this.config.virtualRowHeight === 'function'
2666
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;
2667
3002
  let y = 0;
2668
3003
  for (let i = 0; i < n; i++) {
2669
3004
  let h = v.base;
@@ -2671,6 +3006,7 @@ export class DataTable {
2671
3006
  const asked = fn(v.globalOf(i), v.base);
2672
3007
  h = Number.isFinite(asked) && asked > 0 ? asked : 0;
2673
3008
  }
3009
+ if (details && h > 0) h += this._detailHeightOf(v.rows[i], v.globalOf(i), v.base);
2674
3010
  heights[i] = h;
2675
3011
  offsets[i] = y;
2676
3012
  y += h;
@@ -2695,26 +3031,67 @@ export class DataTable {
2695
3031
  return lo;
2696
3032
  }
2697
3033
 
2698
- /** Measure one real row. Client pixels, so the zoom is divided back out —
2699
- * `_measureNaturalWidths` explains why — and NOT `offsetHeight`, which is
2700
- * rounded: 21 for a 21.19px row is a 1,100px error over 6,000 rows. */
3034
+ /**
3035
+ * Measure the rows that are DRAWN — the median of up to 64 of them. Client
3036
+ * pixels, so the zoom is divided back out (`_measureNaturalWidths` explains
3037
+ * why), and NOT `offsetHeight`, which is rounded: 21 for a 21.19px row is a
3038
+ * 1,100px error over 6,000 rows.
3039
+ *
3040
+ * 0.6.3: IT USED TO MEASURE ONE ROW, ONCE, AT RENDER, and trust it for the
3041
+ * life of the table. A row measured at a bad moment — a box not yet laid
3042
+ * out at its width, a font not yet loaded — set every row's height: a
3043
+ * Tables grid drawing 22.8px rows was found holding a 134px estimate, so the
3044
+ * window took a 650px viewport for five rows, drew rows 1–35 and left the
3045
+ * rest of the box blank spacer while the reader scrolled. Now it is asked
3046
+ * again on every window update (`_virtUpdate`), on a resize and once the
3047
+ * column widths are fixed, and corrects itself the first time real rows
3048
+ * are on screen. The first measure is exact; after that a change has to be
3049
+ * larger than half a pixel, so rows of mixed heights cannot make the
3050
+ * offsets move under the reader on every scroll.
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
+ *
3060
+ * @returns {boolean} whether the heights changed (the offsets are recomputed).
3061
+ */
2701
3062
  _virtMeasure() {
2702
3063
  const v = this._virt;
2703
3064
  if (!v) return false;
3065
+ const zoom = this._tableEl?.currentCSSZoom || 1;
3066
+ const heights = [];
2704
3067
  for (const tr of v.map.values()) {
2705
3068
  if (!tr.isConnected || tr.hidden) continue;
2706
- const zoom = this._tableEl?.currentCSSZoom || 1;
2707
3069
  const h = tr.getBoundingClientRect().height / zoom;
2708
- if (!(h > 0)) return false;
2709
- if (Math.abs(h - v.base) > 0.01) {
3070
+ if (h > 0) heights.push(h);
3071
+ if (heights.length >= 64) break;
3072
+ }
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) {
2710
3082
  v.base = h;
2711
3083
  this._virtBase = h;
2712
- this._virtComputeOffsets();
2713
- return true;
3084
+ base = true;
2714
3085
  }
2715
- return false;
2716
3086
  }
2717
- return false;
3087
+ if (!base && !details) return false;
3088
+ this._virtComputeOffsets();
3089
+ return true;
3090
+ }
3091
+
3092
+ /** Measure again, and redraw the window when the heights moved. */
3093
+ _virtRemeasure() {
3094
+ if (this._virtMeasure()) this._virtUpdate({ force: true });
2718
3095
  }
2719
3096
 
2720
3097
  /**
@@ -2734,9 +3111,12 @@ export class DataTable {
2734
3111
  * @param {number} [o.viewport] the viewport height, likewise.
2735
3112
  * @returns {HTMLElement[]} the rows added.
2736
3113
  */
2737
- _virtUpdate({ force = false, notify = true, top = null, viewport = null } = {}) {
3114
+ _virtUpdate({ force = false, notify = true, top = null, viewport = null, measure = true } = {}) {
2738
3115
  const v = this._virt;
2739
3116
  if (!v || !v.offsets) return [];
3117
+ // The rows on screen say how tall a row is — before the window is
3118
+ // placed, so a stale estimate is not used to place it (`_virtMeasure`).
3119
+ if (measure && this._virtMeasure()) force = true;
2740
3120
  const n = v.rows.length;
2741
3121
  const wrap = this._tableWrapEl;
2742
3122
  const view = viewport || wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
@@ -2764,6 +3144,13 @@ export class DataTable {
2764
3144
  console.error('[DataTable] onRowsRendered threw', err);
2765
3145
  }
2766
3146
  }
3147
+ // And the rows just drawn: if they disagree with the estimate the
3148
+ // window was placed by, place it once more by what they say.
3149
+ if (measure && added.length && this._virtMeasure()) {
3150
+ return added.concat(this._virtUpdate({
3151
+ force: true, notify, top, viewport, measure: false,
3152
+ }));
3153
+ }
2767
3154
  }
2768
3155
  return added;
2769
3156
  }
@@ -2777,7 +3164,12 @@ export class DataTable {
2777
3164
  for (let i = start; i < end; i++) if (v.heights[i] > 0) want.push(i);
2778
3165
  const wanted = new Set(want);
2779
3166
  for (const [i, tr] of v.map) {
2780
- 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
+ }
2781
3173
  }
2782
3174
  v.start = start;
2783
3175
  v.end = end;
@@ -2794,10 +3186,14 @@ export class DataTable {
2794
3186
  const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
2795
3187
  v.map.set(i, tr);
2796
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);
2797
3191
  ref = tr;
2798
3192
  added.push(tr);
2799
3193
  }
2800
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);
2801
3197
  return added;
2802
3198
  }
2803
3199
 
@@ -3013,6 +3409,9 @@ export class DataTable {
3013
3409
  if (colIdx === 0 && typeof this.config.rowIcon === 'function') {
3014
3410
  this._prependRowIcon(td, this.config.rowIcon(row, globalIdx));
3015
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);
3016
3415
 
3017
3416
  // Cell-level right-click hook (P2). Fires before the row-level
3018
3417
  // hook and the built-in context menu — the caller can
@@ -3132,6 +3531,8 @@ export class DataTable {
3132
3531
  // 0.5.0. The row's data changed, so its classes, attributes, key and
3133
3532
  // open mark are asked again (a key paused, a delivery that died).
3134
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);
3135
3536
 
3136
3537
  if (focusedCol >= 0 && tr.children[focusedCol]) {
3137
3538
  tr.children[focusedCol].focus?.();
@@ -3139,6 +3540,1168 @@ export class DataTable {
3139
3540
  return true;
3140
3541
  }
3141
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
+
3142
4705
  // ─────────────────────────────────────────────────────────────────
3143
4706
  // 0.5.0 — decoration, states and the typed columns
3144
4707
  // ─────────────────────────────────────────────────────────────────
@@ -3371,6 +4934,10 @@ export class DataTable {
3371
4934
  tr.__rowKey = this._rowKey(row, rowIdx);
3372
4935
  }
3373
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
+ }
3374
4941
  }
3375
4942
 
3376
4943
  /** The open mark on one row, from `_activeKey`. */
@@ -3575,6 +5142,30 @@ export class DataTable {
3575
5142
  : 0;
3576
5143
  natural[i] = Math.max(body, head);
3577
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;
3578
5169
 
3579
5170
  for (const tr of sampled) tr.remove();
3580
5171
  headerTable.classList.remove('twm-dt-measuring');
@@ -3763,6 +5354,7 @@ export class DataTable {
3763
5354
  firstIdx,
3764
5355
  overrides: this._colWidths,
3765
5356
  fit: this.config.columnFit,
5357
+ floors: this._filterFloors,
3766
5358
  });
3767
5359
  const total = widths.reduce((a, b) => a + b, 0);
3768
5360
 
@@ -3872,6 +5464,11 @@ export class DataTable {
3872
5464
  const n = natural.length;
3873
5465
  const firstIdx = opts.firstIdx ?? 0;
3874
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));
3875
5472
  // Anything that is not the literal opt-in is the historical behaviour.
3876
5473
  // Read once, so an embedder that passes a typo gets the old layout
3877
5474
  // rather than half of each.
@@ -3952,7 +5549,7 @@ export class DataTable {
3952
5549
  const budget = avail - protectedSum;
3953
5550
  if (budget < shrinkable.length * FLOOR) {
3954
5551
  // Even at the floor we overflow → let the body scroll sideways.
3955
- for (const i of shrinkable) widths[i] = FLOOR;
5552
+ for (const i of shrinkable) widths[i] = floorOf(i);
3956
5553
  return widths;
3957
5554
  }
3958
5555
  // Max-min fair allocation: the narrow columns keep their content,
@@ -3969,7 +5566,7 @@ export class DataTable {
3969
5566
  } else {
3970
5567
  const level = Math.max(FLOOR, fair);
3971
5568
  for (let j = k; j < shrinkable.length; j++) {
3972
- widths[shrinkable[j]] = level;
5569
+ widths[shrinkable[j]] = Math.max(level, floorOf(shrinkable[j]));
3973
5570
  }
3974
5571
  // Dump any rounding remainder onto the widest column.
3975
5572
  const leftover = remaining - colsLeft * level;
@@ -4255,6 +5852,9 @@ export class DataTable {
4255
5852
  : bodyTable.querySelectorAll('tbody td');
4256
5853
  cells.forEach((td) => {
4257
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;
4258
5858
  const clipped = td.scrollWidth > td.clientWidth + 1;
4259
5859
  if (clipped) {
4260
5860
  if (!td.title) td.title = td.textContent;
@@ -4319,6 +5919,25 @@ export class DataTable {
4319
5919
  this._installActivation(tbody, table);
4320
5920
  }
4321
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
+
4322
5941
  if (copyable) {
4323
5942
  const handleContextMenu = (event) => {
4324
5943
  const rowEl = event.target.closest('tr');
@@ -4326,6 +5945,12 @@ export class DataTable {
4326
5945
  this._hideContextMenu();
4327
5946
  return;
4328
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
+ }
4329
5954
 
4330
5955
  event.preventDefault();
4331
5956
  event.stopPropagation();