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.
@@ -358,6 +358,114 @@ function matchDurationFilter(ms, filterText, shown) {
358
358
  return String(shown ?? "").toLowerCase().includes(text.toLowerCase());
359
359
  }
360
360
 
361
+ // src/ui/components/table_filter_chips.js
362
+ var isAll = (v) => v === "" || v == null;
363
+ function createChipsFilter(ctx = {}, spec = {}) {
364
+ const options = Array.isArray(spec.options) ? spec.options : [];
365
+ const multiple = !!spec.multiple;
366
+ const showCounts = spec.counts !== false;
367
+ const el = document.createElement("div");
368
+ el.className = "twm-dt-filter-chips" + (multiple ? " twm-dt-filter-chips--multiple" : "");
369
+ el.setAttribute("role", "group");
370
+ const name = spec.label || ctx.header;
371
+ if (name) el.setAttribute("aria-label", String(name));
372
+ const valueOf = /* @__PURE__ */ new Map();
373
+ const chips = /* @__PURE__ */ new Map();
374
+ let value = null;
375
+ const keyOf = (v) => String(v ?? "");
376
+ const normalise = (v) => {
377
+ if (multiple) {
378
+ const list = Array.isArray(v) ? v : isAll(v) ? [] : [v];
379
+ return list.filter((x) => !isAll(x)).map(keyOf);
380
+ }
381
+ if (Array.isArray(v)) v = v[0];
382
+ return isAll(v) ? null : keyOf(v);
383
+ };
384
+ const chosen = () => multiple ? value.length === 0 : value === null;
385
+ for (const opt of options) {
386
+ const key = keyOf(opt.value);
387
+ valueOf.set(key, opt.value);
388
+ const btn = document.createElement("button");
389
+ btn.type = "button";
390
+ btn.className = "twm-dt-filter-chip";
391
+ btn.dataset.value = key;
392
+ if (opt.title) btn.title = String(opt.title);
393
+ if (opt.icon) {
394
+ const icon = document.createElement("span");
395
+ icon.className = "material-symbols-outlined twm-dt-filter-chip__icon";
396
+ icon.setAttribute("aria-hidden", "true");
397
+ icon.textContent = String(opt.icon);
398
+ btn.appendChild(icon);
399
+ }
400
+ const label = document.createElement("span");
401
+ label.className = "twm-dt-filter-chip__label";
402
+ label.textContent = String(opt.label ?? key);
403
+ btn.appendChild(label);
404
+ let count = null;
405
+ if (showCounts) {
406
+ count = document.createElement("span");
407
+ count.className = "twm-dt-filter-chip__count";
408
+ count.textContent = "\u2013";
409
+ btn.appendChild(count);
410
+ }
411
+ btn.addEventListener("click", (ev) => {
412
+ ev.stopPropagation();
413
+ pick(key);
414
+ });
415
+ chips.set(key, { btn, count });
416
+ el.appendChild(btn);
417
+ }
418
+ function outward() {
419
+ if (multiple) return value.length ? value.map((k) => valueOf.get(k)) : null;
420
+ return value === null ? null : valueOf.get(value);
421
+ }
422
+ function pick(key) {
423
+ if (isAll(key)) value = multiple ? [] : null;
424
+ else if (multiple) {
425
+ value = value.includes(key) ? value.filter((k) => k !== key) : [...value, key];
426
+ } else {
427
+ value = value === key ? null : key;
428
+ }
429
+ paint();
430
+ const out = outward();
431
+ if (typeof ctx.setValue === "function") ctx.setValue(out);
432
+ try {
433
+ spec.onChange?.(out);
434
+ } catch (err) {
435
+ console.error("[chips filter] onChange threw", err);
436
+ }
437
+ }
438
+ function paint() {
439
+ for (const [key, { btn }] of chips) {
440
+ const on = isAll(key) ? chosen() : multiple ? value.includes(key) : value === key;
441
+ btn.classList.toggle("twm-dt-filter-chip--on", on);
442
+ btn.setAttribute("aria-pressed", on ? "true" : "false");
443
+ }
444
+ }
445
+ function setValue(v) {
446
+ value = normalise(v);
447
+ paint();
448
+ }
449
+ function setCounts(counts) {
450
+ for (const [key, { btn, count }] of chips) {
451
+ const n = counts == null ? void 0 : counts[key];
452
+ const known = typeof n === "number" && Number.isFinite(n);
453
+ if (count) count.textContent = known ? n.toLocaleString() : "\u2013";
454
+ btn.classList.toggle("twm-dt-filter-chip--empty", known && n === 0);
455
+ }
456
+ }
457
+ setValue(ctx.value);
458
+ if (ctx.counts) setCounts(ctx.counts);
459
+ return {
460
+ el,
461
+ setValue,
462
+ setCounts,
463
+ getValue: outward,
464
+ destroy() {
465
+ }
466
+ };
467
+ }
468
+
361
469
  // src/ui/components/data_table.js
362
470
  var DEFAULT_PAGE_SIZE = 100;
363
471
  var DIFF_STATUSES = /* @__PURE__ */ new Set(["added", "removed", "changed", "same", "gap"]);
@@ -405,16 +513,24 @@ function autoDisposeSweep(entry) {
405
513
  if (!el) continue;
406
514
  if (el.isConnected) {
407
515
  table._autoSeen = true;
516
+ if (table._autoPending) {
517
+ clearTimeout(table._autoTimer);
518
+ table._autoTimer = null;
519
+ table._autoPending = false;
520
+ }
408
521
  continue;
409
522
  }
410
523
  if (!table._autoSeen || table._autoPending) continue;
524
+ const grace = table._detachGrace();
525
+ if (grace === Infinity) continue;
411
526
  table._autoPending = true;
412
- setTimeout(() => {
527
+ table._autoTimer = setTimeout(() => {
413
528
  table._autoPending = false;
529
+ table._autoTimer = null;
414
530
  if (!entry.tables.has(ref)) return;
415
531
  const now = table._wrapperEl;
416
532
  if (now && !now.isConnected) table._disposeOwn();
417
- }, 0);
533
+ }, grace);
418
534
  }
419
535
  if (entry.tables.size === 0) autoDisposeRelease(entry);
420
536
  }
@@ -460,6 +576,38 @@ function perColumn(option, colIdx) {
460
576
  if (typeof option === "object") return option[colIdx] ?? null;
461
577
  return option;
462
578
  }
579
+ var DETAIL_SEQ = { n: 0 };
580
+ function defaultControlMatch(cell, value) {
581
+ if (Array.isArray(value)) return value.some((v) => String(v ?? "") === String(cell ?? ""));
582
+ if (value && typeof value === "object" && !(value instanceof Date) && ("from" in value || "to" in value)) {
583
+ const asNumber = (x) => {
584
+ if (x == null || x === "") return null;
585
+ if (x instanceof Date) return x.getTime();
586
+ if (typeof x === "number") return x;
587
+ const t = parseDateValue(x);
588
+ if (Number.isFinite(t)) return t;
589
+ const n = parseFloat(x);
590
+ return Number.isFinite(n) ? n : null;
591
+ };
592
+ const c = asNumber(cell);
593
+ if (c === null) return false;
594
+ const lo = asNumber(value.from);
595
+ const hi = asNumber(value.to);
596
+ if (lo !== null && c < lo) return false;
597
+ if (hi !== null && c > hi) return false;
598
+ return true;
599
+ }
600
+ if (typeof value === "string") {
601
+ return String(cell ?? "").toLowerCase().includes(value.toLowerCase());
602
+ }
603
+ return String(value) === String(cell ?? "");
604
+ }
605
+ function chipsMatch(spec) {
606
+ return (cell, value) => {
607
+ const want = Array.isArray(value) ? value : [value];
608
+ return want.some((v) => String(v ?? "") === String(cell ?? ""));
609
+ };
610
+ }
463
611
  function normaliseSortKey(k) {
464
612
  if (k == null) return null;
465
613
  if (k instanceof Date) k = k.getTime();
@@ -574,8 +722,36 @@ var DataTable = class {
574
722
  dateTimeFormat: null,
575
723
  durationFormat: null,
576
724
  dateTimeZone: "local",
725
+ // 0.7.0. Off — or exactly the 0.6 behaviour — until set. The
726
+ // typedef says what each does.
727
+ rowDetail: null,
728
+ hasRowDetail: null,
729
+ expandedRows: null,
730
+ onRowDetailToggle: null,
731
+ rowDetailLabel: "Details",
732
+ dataSource: null,
733
+ onLoad: null,
734
+ onLoadError: null,
735
+ hasMore: void 0,
736
+ pagerLabels: null,
737
+ pagerPosition: "top",
738
+ maxJumpOffset: null,
739
+ follow: false,
740
+ onFollowChange: null,
741
+ keepWhileDetached: false,
742
+ filterKinds: null,
743
+ onFilterChange: null,
577
744
  ...config
578
745
  };
746
+ this._expanded = new Set((this.config.expandedRows || []).map((k) => String(k)));
747
+ this._detailHeights = /* @__PURE__ */ new Map();
748
+ this._source = null;
749
+ this._following = !!this.config.follow && !!this.config.dataSource;
750
+ this._followTimer = null;
751
+ this._newKeys = null;
752
+ this._filterControls = /* @__PURE__ */ new Map();
753
+ this._filterCounts = /* @__PURE__ */ new Map();
754
+ this._pendingFilter = null;
579
755
  this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
580
756
  this._error = null;
581
757
  this._virt = null;
@@ -709,6 +885,17 @@ var DataTable = class {
709
885
  this._state.filters.clear();
710
886
  }
711
887
  }
888
+ if (updates.headers && (this._filterControls.size || this._filterCounts.size)) {
889
+ const oldHeaders = this.config.headers;
890
+ const newHeaders = updates.headers;
891
+ if (oldHeaders.length !== newHeaders.length || oldHeaders.some((h, i) => h !== newHeaders[i])) {
892
+ this._destroyFilterControls();
893
+ this._filterCounts.clear();
894
+ }
895
+ }
896
+ if (Object.prototype.hasOwnProperty.call(updates, "filterKinds")) {
897
+ this._destroyFilterControls();
898
+ }
712
899
  Object.assign(this.config, updates);
713
900
  if (updates.rows) {
714
901
  this._state.selected.clear();
@@ -744,7 +931,7 @@ var DataTable = class {
744
931
  if (!this._wrapperEl) return;
745
932
  this._wrapperEl.classList.toggle(
746
933
  "twm-data-table-component--loading",
747
- !!on && this.config.emptyState !== void 0
934
+ !!on && (this.config.emptyState !== void 0 || !!this.config.dataSource)
748
935
  );
749
936
  let overlay = this._wrapperEl.querySelector(".twm-data-table__loading");
750
937
  if (on) {
@@ -863,6 +1050,13 @@ var DataTable = class {
863
1050
  * @param {number} page - Page number (0-indexed)
864
1051
  */
865
1052
  goToPage(page) {
1053
+ if (this.config.dataSource) {
1054
+ const size = this._sourcePageSize();
1055
+ let p = Math.max(0, Math.floor(Number(page)) || 0);
1056
+ const total = this._source?.total;
1057
+ if (typeof total === "number") p = Math.min(p, Math.max(0, Math.ceil(total / size) - 1));
1058
+ return this._sourceGoTo(p * size);
1059
+ }
866
1060
  const rowCount = this._getProcessedRows().length;
867
1061
  const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
868
1062
  this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
@@ -873,6 +1067,10 @@ var DataTable = class {
873
1067
  * @param {number} rowIndex - Row index
874
1068
  */
875
1069
  goToRow(rowIndex) {
1070
+ if (this.config.dataSource) {
1071
+ const size = this._sourcePageSize();
1072
+ return this.goToPage(Math.floor(Math.max(0, Math.floor(Number(rowIndex)) || 0) / size));
1073
+ }
876
1074
  const rowCount = this._getProcessedRows().length;
877
1075
  const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
878
1076
  this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
@@ -897,6 +1095,7 @@ var DataTable = class {
897
1095
  this._awaitWithSpinner(ret);
898
1096
  this._savePersisted();
899
1097
  this.render();
1098
+ if (this.config.dataSource) this._sourceLoad({ offset: 0, reason: "sort" });
900
1099
  }
901
1100
  /** Internal: if `maybePromise` is a Promise (or thenable),
902
1101
  * toggle the loading overlay around it. No-op otherwise. */
@@ -913,7 +1112,21 @@ var DataTable = class {
913
1112
  this._processedRows = null;
914
1113
  this._processedIndexMap = null;
915
1114
  this._state.offset = 0;
1115
+ this._pendingFilter = null;
916
1116
  this._savePersisted();
1117
+ for (const { control } of this._filterControls.values()) {
1118
+ try {
1119
+ control.setValue?.(null);
1120
+ } catch (err) {
1121
+ console.error("[DataTable] filter control setValue threw", err);
1122
+ }
1123
+ }
1124
+ this._notifyFilterChange();
1125
+ if (this.config.dataSource) {
1126
+ if (this._wrapperEl) this.render();
1127
+ this._sourceLoad({ offset: 0, reason: "filter" });
1128
+ return;
1129
+ }
917
1130
  this.render();
918
1131
  }
919
1132
  /**
@@ -930,6 +1143,8 @@ var DataTable = class {
930
1143
  const activeFilterColIdx = this._getActiveFilterColIdx();
931
1144
  const doc = this.container.ownerDocument;
932
1145
  const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
1146
+ const focus070 = this._captureFocus070(doc);
1147
+ const pendingFilter = this.config.dataSource ? this._pendingFilter : null;
933
1148
  const prevWrap = this._tableWrapEl;
934
1149
  const prevScroll = prevWrap?.isConnected && this._renderedOffset === this._state.offset ? { top: prevWrap.scrollTop, left: prevWrap.scrollLeft } : null;
935
1150
  this._cleanup();
@@ -956,9 +1171,12 @@ var DataTable = class {
956
1171
  this._wrapperEl.style.cssText = "display:flex; flex-direction:column; height:100%; min-height:0;";
957
1172
  }
958
1173
  this._loading = false;
959
- if (pagination && rows.length > 0 && !this._pagingConfig()) {
1174
+ this._paginationEl = null;
1175
+ if (pagination && (rows.length > 0 || (this._source?.offset || 0) > 0) && !this._pagingConfig()) {
960
1176
  this._paginationEl = this._createPagination();
961
- if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
1177
+ if (this._paginationEl && this.config.pagerPosition !== "bottom") {
1178
+ this._wrapperEl.appendChild(this._paginationEl);
1179
+ }
962
1180
  }
963
1181
  const tableWrap = document.createElement("div");
964
1182
  tableWrap.className = this.config.readonly ? "twm-preview-table-wrap twm-preview-table-wrap--wizard" : "twm-preview-table-wrap";
@@ -966,7 +1184,8 @@ var DataTable = class {
966
1184
  if (this._error && rows.length > 0) {
967
1185
  this._wrapperEl.appendChild(this._createErrorBanner());
968
1186
  }
969
- if (rows.length === 0) {
1187
+ const keepFilterRow = rows.length === 0 && !!this.config.dataSource && this.config.filterable;
1188
+ if (rows.length === 0 && !keepFilterRow) {
970
1189
  const { headers, showRowNumbers } = this.config;
971
1190
  const headerWrap = document.createElement("div");
972
1191
  headerWrap.className = "twm-preview-table-header-wrap";
@@ -996,25 +1215,7 @@ var DataTable = class {
996
1215
  const bodyTable = document.createElement("table");
997
1216
  bodyTable.className = headerTable.className;
998
1217
  const tbody = document.createElement("tbody");
999
- const emptyTr = document.createElement("tr");
1000
- emptyTr.className = "data-preview-row data-table__empty-row";
1001
- const colCount = headers.length + (showRowNumbers ? 1 : 0);
1002
- const emptyTd = document.createElement("td");
1003
- emptyTd.colSpan = colCount;
1004
- if (this._error) {
1005
- emptyTd.className = "twm-data-table__empty-cell twm-data-table__empty-cell--error";
1006
- emptyTd.setAttribute("role", "alert");
1007
- this._appendStateContent(emptyTd, this._errorContent());
1008
- } else if (this.config.emptyState !== void 0) {
1009
- emptyTd.className = "twm-data-table__empty-cell twm-data-table__empty-cell--state";
1010
- const state = typeof this.config.emptyState === "function" ? this.config.emptyState() : this.config.emptyState;
1011
- this._appendStateContent(emptyTd, state);
1012
- } else {
1013
- emptyTd.style.cssText = "text-align:center; font-style:italic; color:#888; padding:16px;";
1014
- emptyTd.textContent = emptyMessage;
1015
- }
1016
- emptyTr.appendChild(emptyTd);
1017
- tbody.appendChild(emptyTr);
1218
+ tbody.appendChild(this._buildEmptyRow(headers.length + (showRowNumbers ? 1 : 0), emptyMessage));
1018
1219
  bodyTable.appendChild(tbody);
1019
1220
  tableWrap.appendChild(bodyTable);
1020
1221
  this._wrapperEl.appendChild(tableWrap);
@@ -1038,10 +1239,20 @@ var DataTable = class {
1038
1239
  } else {
1039
1240
  this._headerTableEl = null;
1040
1241
  }
1242
+ if (keepFilterRow && this._tbodyEl) {
1243
+ const n = this.config.headers.length + (this.config.showRowNumbers ? 1 : 0);
1244
+ this._tbodyEl.appendChild(this._buildEmptyRow(n, emptyMessage));
1245
+ }
1041
1246
  tableWrap.appendChild(fullTable);
1042
1247
  this._wrapperEl.appendChild(tableWrap);
1043
1248
  }
1044
1249
  this._tableWrapEl = tableWrap;
1250
+ if (this._paginationEl && this.config.pagerPosition === "bottom") {
1251
+ this._paginationEl.classList.add("twm-pagination-controls--bottom");
1252
+ this._paginationEl.style.borderBottom = "0";
1253
+ this._paginationEl.style.borderTop = "1px solid #2a2a2a";
1254
+ this._wrapperEl.appendChild(this._paginationEl);
1255
+ }
1045
1256
  this._pagingEl = null;
1046
1257
  if (this._pagingConfig()) {
1047
1258
  this._pagingEl = this._createPagingStrip();
@@ -1073,7 +1284,10 @@ var DataTable = class {
1073
1284
  });
1074
1285
  }
1075
1286
  if (this._headerTableEl && this._tableEl) {
1076
- requestAnimationFrame(() => this._syncHeaderWidths());
1287
+ requestAnimationFrame(() => {
1288
+ this._syncHeaderWidths();
1289
+ this._virtRemeasure();
1290
+ });
1077
1291
  if (this._resizeObserver) {
1078
1292
  try {
1079
1293
  this._resizeObserver.disconnect();
@@ -1090,7 +1304,7 @@ var DataTable = class {
1090
1304
  this._installHeaderScrollSync();
1091
1305
  if (rows.length > 0) this._installColumnResizers();
1092
1306
  }
1093
- if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly || typeof this.config.onRowActivate === "function")) {
1307
+ if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly || typeof this.config.onRowActivate === "function" || typeof this.config.rowDetail === "function")) {
1094
1308
  this._installInteractions();
1095
1309
  }
1096
1310
  if (activeFilterColIdx !== null) {
@@ -1100,6 +1314,18 @@ var DataTable = class {
1100
1314
  this._tableEl.focus({ preventScroll: true });
1101
1315
  } catch (_) {
1102
1316
  }
1317
+ } else if (focus070) {
1318
+ this._restoreFocus070(focus070);
1319
+ }
1320
+ if (pendingFilter) this._resumePendingFilter(pendingFilter);
1321
+ if (this.config.dataSource) {
1322
+ if (!this._source?.started) {
1323
+ this._sourceState().started = true;
1324
+ Promise.resolve().then(() => this._sourceLoad({ offset: 0, reason: "initial" }));
1325
+ } else if (this._source.inFlight && !this._source.pendingAppend) {
1326
+ this.setLoading(true);
1327
+ }
1328
+ if (this._following && !this._followTimer) this._armFollow();
1103
1329
  }
1104
1330
  }
1105
1331
  /**
@@ -1202,6 +1428,22 @@ var DataTable = class {
1202
1428
  _teardown(emptyHost) {
1203
1429
  autoDisposeUnwatch(this);
1204
1430
  this._autoSeen = false;
1431
+ if (this._autoTimer) {
1432
+ clearTimeout(this._autoTimer);
1433
+ this._autoTimer = null;
1434
+ }
1435
+ this._autoPending = false;
1436
+ this._disarmFollow();
1437
+ if (this._source) {
1438
+ this._source.ticket++;
1439
+ try {
1440
+ this._source.controller?.abort();
1441
+ } catch (_) {
1442
+ }
1443
+ this._source.controller = null;
1444
+ this._source.inFlight = false;
1445
+ }
1446
+ this._destroyFilterControls();
1205
1447
  this._runOffs(this._activationOffs);
1206
1448
  this._activationOffs = [];
1207
1449
  this._cleanup();
@@ -1241,7 +1483,7 @@ var DataTable = class {
1241
1483
  // ─────────────────────────────────────────────────────────────────
1242
1484
  _getProcessedRows() {
1243
1485
  if (this._processedRows !== null) return this._processedRows;
1244
- const isServerSide = typeof this.config.onPageChange === "function";
1486
+ const isServerSide = this._isServerSide();
1245
1487
  let rows = this.config.rows;
1246
1488
  let indexMap = rows.map((_, i) => i);
1247
1489
  if (!isServerSide && this._state.filters.size > 0) {
@@ -1262,12 +1504,23 @@ var DataTable = class {
1262
1504
  const filters = this._state.filters;
1263
1505
  const filteredRows = [];
1264
1506
  const filteredMap = [];
1507
+ const matchers = /* @__PURE__ */ new Map();
1508
+ for (const colIdx of filters.keys()) matchers.set(colIdx, this._filterMatcher(colIdx));
1265
1509
  for (let i = 0; i < rows.length; i++) {
1266
1510
  const row = rows[i];
1267
1511
  let pass = true;
1268
1512
  for (const [colIdx, filterText] of filters) {
1269
1513
  if (!filterText) continue;
1270
1514
  const value = row[colIdx];
1515
+ const kindMatch = matchers.get(colIdx);
1516
+ if (kindMatch) {
1517
+ if (this._isEmptyFilterValue(filterText)) continue;
1518
+ if (!kindMatch(value, filterText, row)) {
1519
+ pass = false;
1520
+ break;
1521
+ }
1522
+ continue;
1523
+ }
1271
1524
  const colType = this._valueKind(colIdx);
1272
1525
  if (colType === "num") {
1273
1526
  if (!this._matchNumericFilter(value, filterText)) {
@@ -1438,6 +1691,15 @@ var DataTable = class {
1438
1691
  headers.forEach((header, colIdx) => {
1439
1692
  const th = document.createElement("th");
1440
1693
  th.className = "data-table__filter-cell";
1694
+ const control = this._filterControlFor(colIdx, header);
1695
+ if (control) {
1696
+ th.classList.add("data-table__filter-cell--control");
1697
+ tr.classList.add("twm-data-table__filter-row--controls");
1698
+ th.addEventListener("click", (e) => e.stopPropagation());
1699
+ if (control.el) th.appendChild(control.el);
1700
+ tr.appendChild(th);
1701
+ return;
1702
+ }
1441
1703
  const wrapper = document.createElement("div");
1442
1704
  wrapper.className = "twm-data-table__filter-wrapper";
1443
1705
  const input = document.createElement("input");
@@ -1708,23 +1970,55 @@ var DataTable = class {
1708
1970
  if (this._filterDebounceTimer) {
1709
1971
  clearTimeout(this._filterDebounceTimer);
1710
1972
  }
1973
+ this._pendingFilter = { colIdx, value };
1711
1974
  this._filterDebounceTimer = setTimeout(() => {
1975
+ this._pendingFilter = null;
1712
1976
  this._onFilterInput(colIdx, value);
1713
1977
  }, 200);
1714
1978
  }
1715
1979
  _onFilterInput(colIdx, value) {
1716
- if (value) {
1980
+ const empty = typeof value === "string" ? !value : this._isEmptyFilterValue(value);
1981
+ if (!empty) {
1717
1982
  this._state.filters.set(colIdx, value);
1718
1983
  } else {
1719
1984
  this._state.filters.delete(colIdx);
1720
1985
  }
1986
+ this._pendingFilter = null;
1721
1987
  this._invalidateProcessedCache();
1722
1988
  this._state.offset = 0;
1723
1989
  this._state.selected.clear();
1724
1990
  this._state.anchorIndex = null;
1725
1991
  this._savePersisted();
1992
+ const entry = this._filterControls.get(colIdx);
1993
+ if (entry) {
1994
+ try {
1995
+ entry.control.setValue?.(empty ? null : value);
1996
+ } catch (err) {
1997
+ console.error("[DataTable] filter control setValue threw", err);
1998
+ }
1999
+ }
2000
+ this._notifyFilterChange();
2001
+ if (this.config.dataSource) {
2002
+ this._sourceLoad({ offset: 0, reason: "filter" });
2003
+ return;
2004
+ }
1726
2005
  this.render();
1727
2006
  }
2007
+ /** 0.7.0. Put back a filter that was still being typed when a render
2008
+ * rebuilt its box: the words, and the debounce that will apply them. */
2009
+ _resumePendingFilter({ colIdx, value }) {
2010
+ const filterRow = this._wrapperEl?.querySelector(".twm-data-table__filter-row");
2011
+ const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
2012
+ const input = cell?.querySelector(".twm-data-table__filter-input");
2013
+ if (input && input.value !== value) {
2014
+ const focused = input.ownerDocument?.activeElement === input;
2015
+ input.value = value;
2016
+ const clear = cell.querySelector(".twm-data-table__filter-clear");
2017
+ if (clear) clear.style.display = value ? "" : "none";
2018
+ if (focused) input.setSelectionRange(value.length, value.length);
2019
+ }
2020
+ this._onFilterInputDebounced(colIdx, value);
2021
+ }
1728
2022
  _getActiveFilterColIdx() {
1729
2023
  const active = document.activeElement;
1730
2024
  if (!active || !active.classList.contains("twm-data-table__filter-input")) return null;
@@ -1818,6 +2112,7 @@ var DataTable = class {
1818
2112
  return types;
1819
2113
  }
1820
2114
  _createPagination() {
2115
+ if (this._usesPager070()) return this._createPager070();
1821
2116
  const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
1822
2117
  const isServerSide = typeof onPageChange === "function";
1823
2118
  const offset = isServerSide ? configOffset || 0 : this._state.offset;
@@ -1940,6 +2235,25 @@ var DataTable = class {
1940
2235
  const p = this.config.paging;
1941
2236
  if (!p || typeof p !== "object") return null;
1942
2237
  const size = Number.isFinite(p.pageSize) && p.pageSize >= 1 ? Math.floor(p.pageSize) : this.config.pageSize;
2238
+ if (this.config.dataSource) {
2239
+ const src = this._source || { offset: 0, total: null, atLeast: 0, hasMore: false };
2240
+ return {
2241
+ ...p,
2242
+ pageSize: size,
2243
+ offset: src.offset || 0,
2244
+ held: this.config.rows?.length || 0,
2245
+ total: src.total,
2246
+ atLeast: src.atLeast || 0,
2247
+ hasMore: !!src.hasMore,
2248
+ loadMorePx: Number.isFinite(p.loadMorePx) ? p.loadMorePx : PAGING_LOAD_MORE_PX,
2249
+ onPage: (offset) => this._sourceGoTo(offset),
2250
+ onLoadMore: () => this._sourceLoadMore(),
2251
+ onLoadAll: () => this._sourceLoadAll(),
2252
+ // The switch is drawn, as in 0.6.2, only for a consumer that
2253
+ // listens; the table switches and then tells it.
2254
+ onModeChange: typeof p.onModeChange === "function" ? (mode) => this._sourceSetMode(mode) : void 0
2255
+ };
2256
+ }
1943
2257
  return {
1944
2258
  ...p,
1945
2259
  pageSize: size,
@@ -1975,7 +2289,12 @@ var DataTable = class {
1975
2289
  */
1976
2290
  setPaging(patch = {}) {
1977
2291
  if (!this.config.paging) return;
2292
+ const before = this.config.dataSource ? this.pagingState?.mode : null;
1978
2293
  this.config.paging = { ...this.config.paging, ...patch };
2294
+ if (before && this.pagingState?.mode !== before) {
2295
+ this._sourceLoad({ offset: 0, reason: "mode" });
2296
+ return;
2297
+ }
1979
2298
  this._refreshPagingStrip();
1980
2299
  this._pagingMaybeLoadMore();
1981
2300
  }
@@ -2029,6 +2348,7 @@ var DataTable = class {
2029
2348
  _pagingMaybeLoadMore() {
2030
2349
  const p = this._pagingConfig();
2031
2350
  if (!p || !p.hasMore || this._pagingBusy) return;
2351
+ if (this.config.dataSource && this._source?.inFlight) return;
2032
2352
  const state = this.pagingState;
2033
2353
  if (state.mode !== "scroll" || p.held >= state.hardLimit) return;
2034
2354
  const wrap = this._tableWrapEl;
@@ -2065,19 +2385,22 @@ var DataTable = class {
2065
2385
  if (exact) of = ` of ${n(total)}`;
2066
2386
  else if (known > to) of = ` of more than ${n(known - 1)}`;
2067
2387
  info.textContent = held ? `Rows ${n(from)}\u2013${n(to)}${of}` : "No rows on this page";
2068
- const hasPrev = p.offset > 0;
2069
- const hasNext = exact ? p.offset + held < total : p.hasMore;
2070
- const lastOffset = exact && total > 0 ? Math.floor((total - 1) / pageSize) * pageSize : null;
2388
+ const pn = this._pageNav({ offset: p.offset, held, total, hasMore: p.hasMore, pageSize });
2389
+ const { hasPrev, hasNext } = pn;
2390
+ const lastOffset = exact && total > 0 ? pn.lastOffset : null;
2071
2391
  const go = (offset) => (ev) => {
2072
2392
  if (!ev?.currentTarget?.disabled) this._pagingCall("page", p.onPage, offset, pageSize);
2073
2393
  };
2074
2394
  const nav = document.createElement("span");
2075
2395
  nav.className = "twm-dt-paging__nav";
2076
2396
  const off = !!busy || typeof p.onPage !== "function";
2397
+ const words = this._usesPager070();
2398
+ const button = (icon, name, tip, disabled, onClick, refusal = null) => words ? this._pagerButton(icon, name, tip, disabled, onClick, refusal) : this._createPaginationBtn(icon, tip, disabled, onClick);
2077
2399
  nav.append(
2078
- this._createPaginationBtn("first_page", "First page", off || !hasPrev, go(0)),
2079
- this._createPaginationBtn(
2400
+ button("first_page", "first", "First page", off || !hasPrev, go(0)),
2401
+ button(
2080
2402
  "chevron_left",
2403
+ "prev",
2081
2404
  "Previous page",
2082
2405
  off || !hasPrev,
2083
2406
  go(Math.max(0, p.offset - pageSize))
@@ -2085,20 +2408,28 @@ var DataTable = class {
2085
2408
  );
2086
2409
  const page = document.createElement("span");
2087
2410
  page.className = "twm-dt-paging__page";
2088
- const current = Math.floor(p.offset / pageSize) + 1;
2089
- page.textContent = exact ? `Page ${n(current)} of ${n(Math.max(1, Math.ceil(total / pageSize)))}` : `Page ${n(current)}`;
2411
+ page.textContent = exact ? `Page ${n(pn.page)} of ${n(pn.pages)}` : `Page ${n(pn.page)}`;
2090
2412
  nav.append(
2091
2413
  page,
2092
- this._createPaginationBtn(
2414
+ button(
2093
2415
  "chevron_right",
2416
+ "next",
2094
2417
  "Next page",
2095
2418
  off || !hasNext,
2096
2419
  go(p.offset + pageSize)
2097
2420
  ),
2098
2421
  // The last page needs the exact count: without it there is no
2099
2422
  // offset to go to, and the button says so by being off.
2100
- this._createPaginationBtn(
2423
+ words ? button(
2101
2424
  "last_page",
2425
+ "last",
2426
+ "Last page",
2427
+ off || !hasNext || lastOffset === null,
2428
+ go(lastOffset),
2429
+ pn.lastRefusal
2430
+ ) : button(
2431
+ "last_page",
2432
+ "last",
2102
2433
  lastOffset === null ? "Last page (the total is not known)" : "Last page",
2103
2434
  off || !hasNext || lastOffset === null,
2104
2435
  go(lastOffset)
@@ -2156,9 +2487,10 @@ var DataTable = class {
2156
2487
  return el;
2157
2488
  }
2158
2489
  _createTable() {
2159
- const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
2490
+ const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon } = this.config;
2160
2491
  const { sortColumn, sortAscending } = this._state;
2161
- const isServerSide = typeof onPageChange === "function";
2492
+ const configOffset = this.config.dataSource ? 0 : this.config.offset;
2493
+ const isServerSide = this._isServerSide();
2162
2494
  const processedRows = isServerSide ? rows : this._getProcessedRows();
2163
2495
  const offset = isServerSide ? configOffset || 0 : this._state.offset;
2164
2496
  const pageRows = isServerSide || !this.config.pagination || this._pagingConfig() ? processedRows : processedRows.slice(offset, offset + pageSize);
@@ -2223,8 +2555,8 @@ var DataTable = class {
2223
2555
  const processedIdx = offset + localIdx;
2224
2556
  return isServerSide ? (configOffset || 0) + localIdx : this._processedIndexMap?.[processedIdx] ?? processedIdx;
2225
2557
  };
2226
- this._positionBase = isServerSide ? configOffset || 0 : this.config.pagination ? offset : 0;
2227
- const paged = this._pagingConfig();
2558
+ this._positionBase = this.config.dataSource ? this._source?.offset || 0 : isServerSide ? configOffset || 0 : this.config.pagination ? offset : 0;
2559
+ const paged = this.config.dataSource ? null : this._pagingConfig();
2228
2560
  if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
2229
2561
  const tbody = document.createElement("tbody");
2230
2562
  table.appendChild(tbody);
@@ -2237,7 +2569,9 @@ var DataTable = class {
2237
2569
  });
2238
2570
  } else {
2239
2571
  pageRows.forEach((row, localIdx) => {
2240
- tbody.appendChild(this._buildBodyRow(row, globalOf(localIdx), localIdx));
2572
+ const tr = this._buildBodyRow(row, globalOf(localIdx), localIdx);
2573
+ tbody.appendChild(tr);
2574
+ if (this._detailOn()) this._appendDetailIfOpen(tr, null);
2241
2575
  });
2242
2576
  }
2243
2577
  return table;
@@ -2381,6 +2715,7 @@ var DataTable = class {
2381
2715
  const heights = new Float64Array(n);
2382
2716
  const offsets = new Float64Array(n + 1);
2383
2717
  const fn = typeof this.config.virtualRowHeight === "function" ? this.config.virtualRowHeight : null;
2718
+ const details = this._detailOn() && this._expanded.size > 0;
2384
2719
  let y = 0;
2385
2720
  for (let i = 0; i < n; i++) {
2386
2721
  let h = v.base;
@@ -2388,6 +2723,7 @@ var DataTable = class {
2388
2723
  const asked = fn(v.globalOf(i), v.base);
2389
2724
  h = Number.isFinite(asked) && asked > 0 ? asked : 0;
2390
2725
  }
2726
+ if (details && h > 0) h += this._detailHeightOf(v.rows[i], v.globalOf(i), v.base);
2391
2727
  heights[i] = h;
2392
2728
  offsets[i] = y;
2393
2729
  y += h;
@@ -2411,26 +2747,65 @@ var DataTable = class {
2411
2747
  }
2412
2748
  return lo;
2413
2749
  }
2414
- /** Measure one real row. Client pixels, so the zoom is divided back out —
2415
- * `_measureNaturalWidths` explains why — and NOT `offsetHeight`, which is
2416
- * rounded: 21 for a 21.19px row is a 1,100px error over 6,000 rows. */
2750
+ /**
2751
+ * Measure the rows that are DRAWN — the median of up to 64 of them. Client
2752
+ * pixels, so the zoom is divided back out (`_measureNaturalWidths` explains
2753
+ * why), and NOT `offsetHeight`, which is rounded: 21 for a 21.19px row is a
2754
+ * 1,100px error over 6,000 rows.
2755
+ *
2756
+ * 0.6.3: IT USED TO MEASURE ONE ROW, ONCE, AT RENDER, and trust it for the
2757
+ * life of the table. A row measured at a bad moment — a box not yet laid
2758
+ * out at its width, a font not yet loaded — set every row's height: a
2759
+ * Tables grid drawing 22.8px rows was found holding a 134px estimate, so the
2760
+ * window took a 650px viewport for five rows, drew rows 1–35 and left the
2761
+ * rest of the box blank spacer while the reader scrolled. Now it is asked
2762
+ * again on every window update (`_virtUpdate`), on a resize and once the
2763
+ * column widths are fixed, and corrects itself the first time real rows
2764
+ * are on screen. The first measure is exact; after that a change has to be
2765
+ * larger than half a pixel, so rows of mixed heights cannot make the
2766
+ * offsets move under the reader on every scroll.
2767
+ *
2768
+ * 0.7.0 `rowDetail`: AN OPEN DETAIL IS PART OF ITS ROW'S HEIGHT, and is
2769
+ * measured at the same moments. It is never one of the rows the median is
2770
+ * taken from (the window's map holds the rows, not their details), so a
2771
+ * tall detail cannot pass for a row; it is read on its own, into the
2772
+ * row's height — and read again here, because a detail's height moves for
2773
+ * the same reasons a row's does (the column widths fixed, a font), and
2774
+ * more: it wraps across the whole table's width.
2775
+ *
2776
+ * @returns {boolean} whether the heights changed (the offsets are recomputed).
2777
+ */
2417
2778
  _virtMeasure() {
2418
2779
  const v = this._virt;
2419
2780
  if (!v) return false;
2781
+ const zoom = this._tableEl?.currentCSSZoom || 1;
2782
+ const heights = [];
2420
2783
  for (const tr of v.map.values()) {
2421
2784
  if (!tr.isConnected || tr.hidden) continue;
2422
- const zoom = this._tableEl?.currentCSSZoom || 1;
2423
2785
  const h = tr.getBoundingClientRect().height / zoom;
2424
- if (!(h > 0)) return false;
2425
- if (Math.abs(h - v.base) > 0.01) {
2786
+ if (h > 0) heights.push(h);
2787
+ if (heights.length >= 64) break;
2788
+ }
2789
+ const details = this._detailOn() && this._expanded.size > 0 && this._virtReadDetailHeights(v.map.values());
2790
+ let base = false;
2791
+ if (heights.length) {
2792
+ heights.sort((a, b) => a - b);
2793
+ const h = heights[heights.length >> 1];
2794
+ const tolerance = v.measured ? 0.5 : 0.01;
2795
+ v.measured = true;
2796
+ if (Math.abs(h - v.base) > tolerance) {
2426
2797
  v.base = h;
2427
2798
  this._virtBase = h;
2428
- this._virtComputeOffsets();
2429
- return true;
2799
+ base = true;
2430
2800
  }
2431
- return false;
2432
2801
  }
2433
- return false;
2802
+ if (!base && !details) return false;
2803
+ this._virtComputeOffsets();
2804
+ return true;
2805
+ }
2806
+ /** Measure again, and redraw the window when the heights moved. */
2807
+ _virtRemeasure() {
2808
+ if (this._virtMeasure()) this._virtUpdate({ force: true });
2434
2809
  }
2435
2810
  /**
2436
2811
  * Slide the window to wherever the scroll box now is.
@@ -2449,9 +2824,10 @@ var DataTable = class {
2449
2824
  * @param {number} [o.viewport] the viewport height, likewise.
2450
2825
  * @returns {HTMLElement[]} the rows added.
2451
2826
  */
2452
- _virtUpdate({ force = false, notify = true, top = null, viewport = null } = {}) {
2827
+ _virtUpdate({ force = false, notify = true, top = null, viewport = null, measure = true } = {}) {
2453
2828
  const v = this._virt;
2454
2829
  if (!v || !v.offsets) return [];
2830
+ if (measure && this._virtMeasure()) force = true;
2455
2831
  const n = v.rows.length;
2456
2832
  const wrap = this._tableWrapEl;
2457
2833
  const view = viewport || wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
@@ -2479,6 +2855,15 @@ var DataTable = class {
2479
2855
  console.error("[DataTable] onRowsRendered threw", err);
2480
2856
  }
2481
2857
  }
2858
+ if (measure && added.length && this._virtMeasure()) {
2859
+ return added.concat(this._virtUpdate({
2860
+ force: true,
2861
+ notify,
2862
+ top,
2863
+ viewport,
2864
+ measure: false
2865
+ }));
2866
+ }
2482
2867
  }
2483
2868
  return added;
2484
2869
  }
@@ -2493,6 +2878,7 @@ var DataTable = class {
2493
2878
  for (const [i, tr] of v.map) {
2494
2879
  if (!wanted.has(i)) {
2495
2880
  tr.remove();
2881
+ this._removeDetailPieces(tr);
2496
2882
  v.map.delete(i);
2497
2883
  removed?.push(tr);
2498
2884
  }
@@ -2512,10 +2898,12 @@ var DataTable = class {
2512
2898
  const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
2513
2899
  v.map.set(i, tr);
2514
2900
  tbody.insertBefore(tr, ref);
2901
+ if (this._detailOn()) this._appendDetailIfOpen(tr, ref);
2515
2902
  ref = tr;
2516
2903
  added.push(tr);
2517
2904
  }
2518
2905
  added.reverse();
2906
+ if (this._detailOn() && added.length) this._virtMeasureDetails(added);
2519
2907
  return added;
2520
2908
  }
2521
2909
  /**
@@ -2715,6 +3103,7 @@ var DataTable = class {
2715
3103
  if (colIdx === 0 && typeof this.config.rowIcon === "function") {
2716
3104
  this._prependRowIcon(td, this.config.rowIcon(row, globalIdx));
2717
3105
  }
3106
+ if (colIdx === 0 && this._detailOn()) this._prependDetailToggle(td, tr, row, globalIdx);
2718
3107
  if (this.config.onCellContextMenu) {
2719
3108
  const cellColIdx = colIdx;
2720
3109
  td.addEventListener("contextmenu", (ev) => {
@@ -2799,12 +3188,1141 @@ var DataTable = class {
2799
3188
  }
2800
3189
  tr.classList.toggle("selected", this._state?.selected?.has(index) === true);
2801
3190
  this._decorateRow(tr, row, index);
3191
+ if (this._detailOn()) this._redrawDetail(tr);
2802
3192
  if (focusedCol >= 0 && tr.children[focusedCol]) {
2803
3193
  tr.children[focusedCol].focus?.();
2804
3194
  }
2805
3195
  return true;
2806
3196
  }
2807
3197
  // ─────────────────────────────────────────────────────────────────
3198
+ // 0.7.0 — a row's detail (#26)
3199
+ // ─────────────────────────────────────────────────────────────────
3200
+ //
3201
+ // A detail is TWO rows after its row: the detail itself (one cell across
3202
+ // every column) and a hidden filler. The filler is the stripe: rows are
3203
+ // striped by `tr:nth-child(even)`, a question about POSITION, and one extra
3204
+ // row would flip the stripe of every row below an open detail. Two keep
3205
+ // the parity — the trick `_virtSpacers` already plays for the window.
3206
+ //
3207
+ // Neither row has a `__rowIndex`, so everything that walks the body for
3208
+ // rows of data (selection, the open mark, `_firstBodyRow`, `getRowElement`,
3209
+ // the activation and the copy menu) steps over them as it steps over a
3210
+ // spacer.
3211
+ _detailOn() {
3212
+ return typeof this.config.rowDetail === "function";
3213
+ }
3214
+ _rowHasDetail(row, rowIdx) {
3215
+ const fn = this.config.hasRowDetail;
3216
+ if (typeof fn !== "function") return true;
3217
+ try {
3218
+ return !!fn(row, rowIdx);
3219
+ } catch (err) {
3220
+ console.error("[DataTable] hasRowDetail threw", err);
3221
+ return false;
3222
+ }
3223
+ }
3224
+ /** The toggle: a real `<button>` (so Enter and Space work on it, and it is
3225
+ * a control `onRowActivate` leaves alone), its glyph drawn by CSS so the
3226
+ * cell's text, its tooltip and a copy stay the cell's own. */
3227
+ _prependDetailToggle(td, tr, row, rowIdx) {
3228
+ if (!this._rowHasDetail(row, rowIdx)) {
3229
+ const gap = document.createElement("span");
3230
+ gap.className = "twm-dt-detail-toggle-gap";
3231
+ gap.setAttribute("aria-hidden", "true");
3232
+ td.insertBefore(gap, td.firstChild);
3233
+ return;
3234
+ }
3235
+ const key = this._rowKey(row, rowIdx);
3236
+ const open = key !== null && this._expanded.has(key);
3237
+ const btn = document.createElement("button");
3238
+ btn.type = "button";
3239
+ btn.className = "material-symbols-outlined twm-dt-detail-toggle";
3240
+ btn.setAttribute("aria-expanded", open ? "true" : "false");
3241
+ btn.setAttribute("aria-label", String(this.config.rowDetailLabel || "Details"));
3242
+ btn.title = String(this.config.rowDetailLabel || "Details");
3243
+ btn.addEventListener("click", (ev) => {
3244
+ ev.preventDefault();
3245
+ ev.stopPropagation();
3246
+ this._setRowElExpanded(tr, btn.getAttribute("aria-expanded") !== "true", ev);
3247
+ });
3248
+ td.insertBefore(btn, td.firstChild);
3249
+ td.classList.add("twm-dt-cell--has-detail-toggle");
3250
+ tr.classList.add("twm-dt-row--has-detail");
3251
+ }
3252
+ /** After `tr` has been placed: its open detail, in front of `ref` (the row
3253
+ * after it, or null for the end of the body). */
3254
+ _appendDetailIfOpen(tr, ref) {
3255
+ const row = tr.__row;
3256
+ const idx = tr.__rowIndex;
3257
+ if (!this._rowHasDetail(row, idx)) return;
3258
+ const key = this._rowKey(row, idx);
3259
+ if (key === null || !this._expanded.has(key)) return;
3260
+ const pieces = this._buildDetailPieces(tr, key);
3261
+ const parent = tr.parentNode;
3262
+ if (!parent) return;
3263
+ for (const p of pieces) parent.insertBefore(p, ref);
3264
+ }
3265
+ _buildDetailPieces(tr, key) {
3266
+ const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
3267
+ const detailTr = document.createElement("tr");
3268
+ detailTr.className = "twm-dt-detail-row";
3269
+ const td = document.createElement("td");
3270
+ td.className = "twm-dt-detail-cell";
3271
+ td.colSpan = Math.max(1, n);
3272
+ const host = document.createElement("div");
3273
+ host.className = "twm-dt-detail";
3274
+ DETAIL_SEQ.n += 1;
3275
+ host.id = `twm-dt-detail-${DETAIL_SEQ.n}`;
3276
+ td.appendChild(host);
3277
+ detailTr.appendChild(td);
3278
+ const filler = document.createElement("tr");
3279
+ filler.className = "twm-dt-detail-filler";
3280
+ filler.hidden = true;
3281
+ filler.setAttribute("aria-hidden", "true");
3282
+ detailTr.__detailOf = tr;
3283
+ tr.__detail = [detailTr, filler];
3284
+ this._drawDetail(tr, host, key);
3285
+ const btn = tr.querySelector(":scope > td > .twm-dt-detail-toggle");
3286
+ if (btn) btn.setAttribute("aria-controls", host.id);
3287
+ return tr.__detail;
3288
+ }
3289
+ _drawDetail(tr, host, key) {
3290
+ host.replaceChildren();
3291
+ let out;
3292
+ try {
3293
+ out = this.config.rowDetail(tr.__row, tr.__rowIndex, { host, key, table: this });
3294
+ } catch (err) {
3295
+ console.error("[DataTable] rowDetail threw", err);
3296
+ out = err?.message || String(err);
3297
+ }
3298
+ if (out == null || out === false || out === host) return;
3299
+ if (typeof Node !== "undefined" && out instanceof Node) host.appendChild(out);
3300
+ else host.textContent = String(out);
3301
+ }
3302
+ _removeDetailPieces(tr) {
3303
+ const pieces = tr.__detail;
3304
+ if (!pieces) return;
3305
+ for (const p of pieces) p.remove();
3306
+ tr.__detail = null;
3307
+ const btn = tr.querySelector(":scope > td > .twm-dt-detail-toggle");
3308
+ if (btn) btn.removeAttribute("aria-controls");
3309
+ }
3310
+ /** `updateRow`'s half: the detail follows the row's new data. */
3311
+ _redrawDetail(tr) {
3312
+ const key = this._rowKey(tr.__row, tr.__rowIndex);
3313
+ const want = this._rowHasDetail(tr.__row, tr.__rowIndex) && key !== null && this._expanded.has(key);
3314
+ const pieces = tr.__detail;
3315
+ if (!want) {
3316
+ if (pieces) this._removeDetailPieces(tr);
3317
+ return;
3318
+ }
3319
+ if (pieces) {
3320
+ const host = pieces[0].querySelector(".twm-dt-detail");
3321
+ if (host) {
3322
+ this._drawDetail(tr, host, key);
3323
+ const btn = tr.querySelector(":scope > td > .twm-dt-detail-toggle");
3324
+ if (btn) btn.setAttribute("aria-controls", host.id);
3325
+ }
3326
+ return;
3327
+ }
3328
+ if (tr.parentNode) this._appendDetailIfOpen(tr, tr.nextSibling);
3329
+ }
3330
+ /** Open or close the detail of the row `tr` draws. Returns whether
3331
+ * anything changed. */
3332
+ _setRowElExpanded(tr, open, ev = null) {
3333
+ if (!this._detailOn() || tr.__rowIndex === void 0) return false;
3334
+ if (!this._rowHasDetail(tr.__row, tr.__rowIndex)) return false;
3335
+ const key = this._rowKey(tr.__row, tr.__rowIndex);
3336
+ if (key === null || this._expanded.has(key) === !!open) return false;
3337
+ if (open) this._expanded.add(key);
3338
+ else this._expanded.delete(key);
3339
+ this._syncDetailOnScreen(tr, key);
3340
+ this._afterDetailChange();
3341
+ try {
3342
+ this.config.onRowDetailToggle?.(tr.__rowIndex, tr.__row, !!open, key, ev);
3343
+ } catch (err) {
3344
+ console.error("[DataTable] onRowDetailToggle threw", err);
3345
+ }
3346
+ return true;
3347
+ }
3348
+ /** Make the row on screen agree with `_expanded` for its key. */
3349
+ _syncDetailOnScreen(tr, key) {
3350
+ const open = this._expanded.has(key);
3351
+ const btn = tr.querySelector(":scope > td > .twm-dt-detail-toggle");
3352
+ if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
3353
+ if (open && !tr.__detail && tr.parentNode) {
3354
+ const ref = tr.nextSibling;
3355
+ for (const p of this._buildDetailPieces(tr, key)) tr.parentNode.insertBefore(p, ref);
3356
+ } else if (!open && tr.__detail) {
3357
+ this._removeDetailPieces(tr);
3358
+ }
3359
+ }
3360
+ /** Under `virtualize` an open detail is part of its row's height. */
3361
+ _afterDetailChange() {
3362
+ if (!this._virt) return;
3363
+ this._virtMeasureDetails([...this._virt.map.values()], { force: true });
3364
+ }
3365
+ /** The detail's height for row `i` of the window's list: 0 closed, the
3366
+ * measured height open, three rows until it has been measured. */
3367
+ _detailHeightOf(row, rowIdx, base) {
3368
+ if (!this._rowHasDetail(row, rowIdx)) return 0;
3369
+ const key = this._rowKey(row, rowIdx);
3370
+ if (key === null || !this._expanded.has(key)) return 0;
3371
+ const h = this._detailHeights.get(key);
3372
+ return h > 0 ? h : base * 3;
3373
+ }
3374
+ /** Measure the open details among `trs` (client pixels, the zoom divided
3375
+ * out, as `_virtMeasure` does), and re-lay the window if one changed. */
3376
+ _virtMeasureDetails(trs, { force = false } = {}) {
3377
+ const v = this._virt;
3378
+ if (!v) return;
3379
+ const changed = this._virtReadDetailHeights(trs) || force;
3380
+ if (!changed) return;
3381
+ this._virtComputeOffsets();
3382
+ this._virtSpacers();
3383
+ }
3384
+ /** Read the open details drawn under `trs` into `_detailHeights`; true
3385
+ * when one moved by more than half a pixel. Recomputes nothing. */
3386
+ _virtReadDetailHeights(trs) {
3387
+ let changed = false;
3388
+ const zoom = this._tableEl?.currentCSSZoom || 1;
3389
+ for (const tr of trs) {
3390
+ const d = tr?.__detail?.[0];
3391
+ if (!d || !d.isConnected) continue;
3392
+ const h = d.getBoundingClientRect().height / zoom;
3393
+ if (!(h > 0)) continue;
3394
+ const key = this._rowKey(tr.__row, tr.__rowIndex);
3395
+ if (Math.abs((this._detailHeights.get(key) || 0) - h) > 0.5) {
3396
+ this._detailHeights.set(key, h);
3397
+ changed = true;
3398
+ }
3399
+ }
3400
+ return changed;
3401
+ }
3402
+ /**
3403
+ * Open (or close) the detail of the row whose key is `key` (`getRowKey`,
3404
+ * else the row's index). A row not on screen — another page, outside the
3405
+ * window, filtered out — opens when it is drawn.
3406
+ *
3407
+ * @param {*} key
3408
+ * @param {boolean} [open=true]
3409
+ * @returns {boolean} whether anything changed
3410
+ */
3411
+ setRowExpanded(key, open = true) {
3412
+ if (key == null) return false;
3413
+ const k = String(key);
3414
+ if (this._expanded.has(k) === !!open) return false;
3415
+ if (open) this._expanded.add(k);
3416
+ else this._expanded.delete(k);
3417
+ const tr = this._rowElByKey(k);
3418
+ if (tr) {
3419
+ this._syncDetailOnScreen(tr, k);
3420
+ try {
3421
+ this.config.onRowDetailToggle?.(tr.__rowIndex, tr.__row, !!open, k, null);
3422
+ } catch (err) {
3423
+ console.error("[DataTable] onRowDetailToggle threw", err);
3424
+ }
3425
+ }
3426
+ if (this._virt) {
3427
+ this._virtComputeOffsets();
3428
+ this._virtSpacers();
3429
+ this._afterDetailChange();
3430
+ }
3431
+ return true;
3432
+ }
3433
+ /** Whether the row whose key is `key` has its detail open. */
3434
+ isRowExpanded(key) {
3435
+ return key != null && this._expanded.has(String(key));
3436
+ }
3437
+ /** The keys of the rows whose detail is open, as text. */
3438
+ getExpandedRows() {
3439
+ return [...this._expanded];
3440
+ }
3441
+ /** Close every detail. */
3442
+ collapseAllRows() {
3443
+ for (const k of [...this._expanded]) this.setRowExpanded(k, false);
3444
+ }
3445
+ /** The `<tr>` on screen for a key, or null. */
3446
+ _rowElByKey(key) {
3447
+ const tbody = this._tbodyEl;
3448
+ if (!tbody) return null;
3449
+ for (const tr of tbody.children) {
3450
+ if (tr.__rowIndex === void 0) continue;
3451
+ if (this._rowKey(tr.__row, tr.__rowIndex) === key) return tr;
3452
+ }
3453
+ return null;
3454
+ }
3455
+ // ─────────────────────────────────────────────────────────────────
3456
+ // 0.7.0 — rows from a server (#33), the pager for an unknown total
3457
+ // (#35), refresh and follow (#36)
3458
+ // ─────────────────────────────────────────────────────────────────
3459
+ //
3460
+ // ONE QUESTION AT A TIME, AND ONLY THE NEWEST ANSWER. Every request takes
3461
+ // a ticket; an answer whose ticket is not the latest is dropped, and the
3462
+ // request it answered is aborted the moment a newer one is sent. Two
3463
+ // requests in flight from a fast typist otherwise race, and the loser
3464
+ // painting last is a list that does not match the box above it.
3465
+ /** Rows that are the current page of something larger (`onPageChange`,
3466
+ * or 0.7.0's `dataSource`): drawn as given, never filtered, sorted or
3467
+ * sliced here. */
3468
+ _isServerSide() {
3469
+ return typeof this.config.onPageChange === "function" || !!this.config.dataSource;
3470
+ }
3471
+ /** `autoDispose`'s grace in ms: one task, `keepWhileDetached`'s number,
3472
+ * or Infinity for `true`. */
3473
+ _detachGrace() {
3474
+ const k = this.config.keepWhileDetached;
3475
+ if (k === true) return Infinity;
3476
+ if (typeof k === "number" && Number.isFinite(k) && k > 0) return k;
3477
+ if (k === Infinity) return Infinity;
3478
+ return 0;
3479
+ }
3480
+ _sourceState() {
3481
+ if (!this._source) {
3482
+ this._source = {
3483
+ started: false,
3484
+ ticket: 0,
3485
+ controller: null,
3486
+ inFlight: false,
3487
+ offset: 0,
3488
+ pendingOffset: 0,
3489
+ total: null,
3490
+ atLeast: 0,
3491
+ hasMore: false,
3492
+ query: null,
3493
+ loaded: false,
3494
+ pendingAppend: false
3495
+ };
3496
+ }
3497
+ return this._source;
3498
+ }
3499
+ /** The column filters, as a query and `onFilterChange` carry them. */
3500
+ _filterList() {
3501
+ const headers = this.config.headers || [];
3502
+ const out = [];
3503
+ for (const [column, value] of this._state.filters) {
3504
+ if (typeof value === "string" ? !value : this._isEmptyFilterValue(value)) continue;
3505
+ out.push({ column, header: headers[column], value });
3506
+ }
3507
+ return out.sort((a, b) => a.column - b.column);
3508
+ }
3509
+ /** Rows per question: `paging.pageSize` when the source is paged, else
3510
+ * `pageSize` — one number for the query, `goToPage` and the pager. */
3511
+ _sourcePageSize() {
3512
+ const size = this._pagingConfig()?.pageSize ?? this.config.pageSize;
3513
+ return Math.max(1, size | 0);
3514
+ }
3515
+ _buildQuery(offset, reason, limit = null) {
3516
+ const col = this._state.sortColumn;
3517
+ return {
3518
+ offset,
3519
+ limit: Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : this._sourcePageSize(),
3520
+ sort: col == null ? null : {
3521
+ column: col,
3522
+ header: (this.config.headers || [])[col],
3523
+ ascending: this._state.sortAscending
3524
+ },
3525
+ filters: this._filterList(),
3526
+ reason
3527
+ };
3528
+ }
3529
+ /**
3530
+ * Ask the source for the page at `offset`, and draw the answer if it is
3531
+ * still the newest by then. Resolves with the answer drawn, or undefined
3532
+ * when it was dropped (superseded, failed, or the table was disposed).
3533
+ */
3534
+ async _sourceLoad({ offset = 0, reason = "reload", keep = false, append = false, limit = null } = {}) {
3535
+ const fetch = this.config.dataSource;
3536
+ if (typeof fetch !== "function") return void 0;
3537
+ const src = this._sourceState();
3538
+ src.started = true;
3539
+ const ticket = ++src.ticket;
3540
+ try {
3541
+ src.controller?.abort();
3542
+ } catch (_) {
3543
+ }
3544
+ const controller = typeof AbortController === "function" ? new AbortController() : null;
3545
+ src.controller = controller;
3546
+ const query = this._buildQuery(Math.max(0, offset | 0), reason, limit);
3547
+ src.query = query;
3548
+ src.inFlight = true;
3549
+ src.pendingOffset = query.offset;
3550
+ src.pendingAppend = append;
3551
+ if (!append) this.setLoading(true);
3552
+ let result;
3553
+ try {
3554
+ result = await fetch(query, { signal: controller?.signal });
3555
+ } catch (err) {
3556
+ if (ticket !== src.ticket) return void 0;
3557
+ src.inFlight = false;
3558
+ src.controller = null;
3559
+ src.pendingAppend = false;
3560
+ if (err?.name === "AbortError" || controller?.signal?.aborted) {
3561
+ this.setLoading(false);
3562
+ return void 0;
3563
+ }
3564
+ this.setError(err);
3565
+ try {
3566
+ this.config.onLoadError?.(err, query);
3567
+ } catch (e) {
3568
+ console.error("[DataTable] onLoadError threw", e);
3569
+ }
3570
+ return void 0;
3571
+ }
3572
+ if (ticket !== src.ticket) return void 0;
3573
+ src.inFlight = false;
3574
+ src.controller = null;
3575
+ src.pendingAppend = false;
3576
+ this._applySourceResult(result, query, { keep, append });
3577
+ try {
3578
+ this.config.onLoad?.(result, query);
3579
+ } catch (e) {
3580
+ console.error("[DataTable] onLoad threw", e);
3581
+ }
3582
+ return result;
3583
+ }
3584
+ _applySourceResult(result, query, { keep = false, append = false } = {}) {
3585
+ const src = this._sourceState();
3586
+ const r = Array.isArray(result) ? { rows: result } : result || {};
3587
+ const answered = Array.isArray(r.rows) ? r.rows : [];
3588
+ const totalRaw = r.total ?? r.totalCount;
3589
+ const total = typeof totalRaw === "number" && Number.isFinite(totalRaw) ? totalRaw : null;
3590
+ const hasMore = typeof r.hasMore === "boolean" ? r.hasMore : total !== null ? query.offset + answered.length < total : answered.length >= query.limit;
3591
+ let rows = append ? (this.config.rows || []).concat(answered) : answered;
3592
+ let rowsOffset = append ? src.offset || 0 : query.offset;
3593
+ const pc = append ? this._pagingConfig() : null;
3594
+ if (pc && resolvePaging({
3595
+ mode: pc.mode,
3596
+ total,
3597
+ atLeast: r.atLeast,
3598
+ held: rowsOffset + rows.length,
3599
+ hasMore,
3600
+ softLimit: pc.softLimit,
3601
+ hardLimit: pc.hardLimit
3602
+ }).mode === "pages") {
3603
+ rows = answered;
3604
+ rowsOffset = query.offset;
3605
+ append = false;
3606
+ }
3607
+ if (src.loaded && this._detailOn() && this._expanded.size && typeof this.config.getRowKey !== "function") {
3608
+ const before = this.config.rows || [];
3609
+ const same = (a, b) => {
3610
+ if (a === b) return true;
3611
+ try {
3612
+ return JSON.stringify(a) === JSON.stringify(b);
3613
+ } catch (_) {
3614
+ return false;
3615
+ }
3616
+ };
3617
+ for (const k of [...this._expanded]) {
3618
+ const i = Number(k);
3619
+ if (!Number.isInteger(i) || i < 0 || i >= rows.length || !same(before[i], rows[i])) {
3620
+ this._expanded.delete(k);
3621
+ this._detailHeights.delete(k);
3622
+ }
3623
+ }
3624
+ }
3625
+ src.offset = rowsOffset;
3626
+ src.total = total;
3627
+ src.atLeast = Number.isFinite(r.atLeast) ? r.atLeast : 0;
3628
+ src.hasMore = hasMore;
3629
+ src.loaded = true;
3630
+ if (r.filterCounts && typeof r.filterCounts === "object") this.setFilterCounts(r.filterCounts);
3631
+ const snap = keep ? this._snapshotView() : null;
3632
+ const kept = append ? this._tableWrapEl?.scrollTop || 0 : null;
3633
+ if (snap && snap.keys && snap.keys.size && typeof this.config.getRowKey === "function") {
3634
+ const fresh = /* @__PURE__ */ new Set();
3635
+ rows.forEach((row, i) => {
3636
+ const k = this._rowKey(row, i);
3637
+ if (k !== null && !snap.keys.has(k)) fresh.add(k);
3638
+ });
3639
+ this._newKeys = fresh.size ? fresh : null;
3640
+ } else {
3641
+ this._newKeys = null;
3642
+ }
3643
+ const chosen = append && this._state.selected.size ? { sel: [...this._state.selected], anchor: this._state.anchorIndex } : null;
3644
+ this.setData({ rows });
3645
+ this._newKeys = null;
3646
+ if (chosen) {
3647
+ for (const i of chosen.sel) this._state.selected.add(i);
3648
+ this._state.anchorIndex = chosen.anchor;
3649
+ this._updateRowSelection();
3650
+ }
3651
+ if (snap) this._restoreView(snap);
3652
+ else if (kept !== null) this._scrollBodyTo(kept);
3653
+ else this._scrollBodyTo(0);
3654
+ }
3655
+ /** What a refresh must put back: the keys on screen, the selection by
3656
+ * key, and the first row a reader can see with where it stood. */
3657
+ _snapshotView() {
3658
+ const rows = this.config.rows || [];
3659
+ const keyed = typeof this.config.getRowKey === "function";
3660
+ const keys = keyed ? new Set(rows.map((row, i) => this._rowKey(row, i))) : null;
3661
+ const selectedKeys = keyed ? this.getSelection().map((i) => this._rowKey(rows[i], i)).filter((k) => k !== null) : null;
3662
+ const wrap = this._tableWrapEl;
3663
+ let anchor = null;
3664
+ const scrollTop = wrap?.scrollTop || 0;
3665
+ if (wrap && scrollTop > 0 && this._tbodyEl) {
3666
+ const zoom = this._tableEl?.currentCSSZoom || 1;
3667
+ const top = wrap.getBoundingClientRect().top;
3668
+ for (const tr of this._tbodyEl.children) {
3669
+ if (tr.__rowIndex === void 0) continue;
3670
+ const box = tr.getBoundingClientRect();
3671
+ if (box.bottom > top + 0.5) {
3672
+ anchor = { key: this._rowKey(tr.__row, tr.__rowIndex), delta: (box.top - top) / zoom };
3673
+ break;
3674
+ }
3675
+ }
3676
+ }
3677
+ return { keys, selectedKeys, anchor, scrollTop };
3678
+ }
3679
+ _restoreView(snap) {
3680
+ if (snap.selectedKeys && snap.selectedKeys.length) {
3681
+ const want = new Set(snap.selectedKeys);
3682
+ const sel = this._state.selected;
3683
+ (this.config.rows || []).forEach((row, i) => {
3684
+ if (want.has(this._rowKey(row, i))) sel.add(i);
3685
+ });
3686
+ if (sel.size) {
3687
+ this._state.anchorIndex = [...sel][0];
3688
+ this._updateRowSelection();
3689
+ this._notifySelectionChange();
3690
+ }
3691
+ }
3692
+ const wrap = this._tableWrapEl;
3693
+ if (!wrap || !snap.anchor || snap.anchor.key === null) return;
3694
+ const rows = this.config.rows || [];
3695
+ const idx = rows.findIndex((row, i) => this._rowKey(row, i) === snap.anchor.key);
3696
+ if (idx < 0) return;
3697
+ const zoom = this._tableEl?.currentCSSZoom || 1;
3698
+ if (this._virt) {
3699
+ const local = this._virtLocalOf(idx);
3700
+ if (local < 0) return;
3701
+ this._scrollBodyTo(Math.max(0, this._virt.offsets[local] - snap.anchor.delta));
3702
+ return;
3703
+ }
3704
+ const tr = this.getRowElement(idx);
3705
+ if (!tr) return;
3706
+ const now = (tr.getBoundingClientRect().top - wrap.getBoundingClientRect().top) / zoom;
3707
+ const shift = now - snap.anchor.delta;
3708
+ if (Math.abs(shift) >= 0.5) this._scrollBodyTo(wrap.scrollTop + shift);
3709
+ }
3710
+ _scrollBodyTo(top) {
3711
+ const wrap = this._tableWrapEl;
3712
+ if (!wrap) return;
3713
+ if (Math.abs((wrap.scrollTop || 0) - top) >= 0.5) wrap.scrollTop = top;
3714
+ if (this._virt) this._virtUpdate({ force: true, top });
3715
+ }
3716
+ /** Go to the page at `offset` (paging back stops following). */
3717
+ _sourceGoTo(offset) {
3718
+ const next = Math.max(0, offset | 0);
3719
+ if (this._following && next > 0) this.setFollow(false);
3720
+ return this._sourceLoad({ offset: next, reason: "page" });
3721
+ }
3722
+ /**
3723
+ * `paging` over a `dataSource`, scrolling: the rows after those held, under
3724
+ * them — never past the hard limit. Scrolling down to ask for them is
3725
+ * paging away, so it turns following off as a page turn does; rows asked
3726
+ * for because the ones held do not fill the box are not (the reader is
3727
+ * still at the top).
3728
+ */
3729
+ _sourceLoadMore() {
3730
+ const held = this.config.rows?.length || 0;
3731
+ const hard = this.pagingState?.hardLimit ?? PAGING_HARD_LIMIT;
3732
+ const limit = Math.min(this._sourcePageSize(), hard - held);
3733
+ if (limit < 1) return Promise.resolve(void 0);
3734
+ if (this._following && (this._tableWrapEl?.scrollTop || 0) > 0) this.setFollow(false);
3735
+ const offset = (this._source?.offset || 0) + held;
3736
+ return this._sourceLoad({ offset, limit, reason: "more", append: true });
3737
+ }
3738
+ /** The strip's *Load all rows*: ONE question for every row left under the
3739
+ * hard limit (`reason: 'all'`); the source answers as many as it will, and
3740
+ * says with `hasMore` whether there are still more. */
3741
+ _sourceLoadAll() {
3742
+ const held = this.config.rows?.length || 0;
3743
+ const hard = this.pagingState?.hardLimit ?? PAGING_HARD_LIMIT;
3744
+ if (hard - held < 1) return Promise.resolve(void 0);
3745
+ if (this._following) this.setFollow(false);
3746
+ const offset = (this._source?.offset || 0) + held;
3747
+ return this._sourceLoad({ offset, limit: hard - held, reason: "all", append: true });
3748
+ }
3749
+ /** The strip's Pages / Scroll switch: the table switches, tells the
3750
+ * consumer (`paging.onModeChange`), and asks again from the start. */
3751
+ _sourceSetMode(mode) {
3752
+ const next = mode === "pages" ? "pages" : "scroll";
3753
+ const p = this.config.paging;
3754
+ this.config.paging = { ...p, mode: next };
3755
+ try {
3756
+ p.onModeChange?.(next);
3757
+ } catch (err) {
3758
+ console.error("[DataTable] onModeChange threw", err);
3759
+ }
3760
+ return this._sourceLoad({ offset: 0, reason: "mode" });
3761
+ }
3762
+ /**
3763
+ * Ask the source again from its FIRST page — for a consumer whose own
3764
+ * filters (outside the table) changed. Under `dataSource` only; resolves
3765
+ * with the answer drawn (undefined when it was superseded or failed).
3766
+ */
3767
+ reload() {
3768
+ if (!this.config.dataSource) return Promise.resolve(void 0);
3769
+ return this._sourceLoad({ offset: 0, reason: "reload" });
3770
+ }
3771
+ /**
3772
+ * Read the CURRENT page again, in place: the scroll position (a reader at
3773
+ * the top sees what arrived; one scrolled down keeps the rows they were
3774
+ * reading where they were), the selection by key, the open row and the
3775
+ * open details all stay; rows the last answer did not hold are drawn
3776
+ * `twm-dt-row--new` (needs `getRowKey`). What `follow` calls on its tick.
3777
+ * Without `dataSource`, it only draws again.
3778
+ */
3779
+ refresh() {
3780
+ if (!this.config.dataSource) {
3781
+ if (this._wrapperEl) this.render();
3782
+ return Promise.resolve(void 0);
3783
+ }
3784
+ const src = this._sourceState();
3785
+ if (this.pagingState?.mode === "scroll") {
3786
+ const held = this.config.rows?.length || 0;
3787
+ return this._sourceLoad({
3788
+ offset: src.offset || 0,
3789
+ limit: Math.max(this._sourcePageSize(), held),
3790
+ reason: "refresh",
3791
+ keep: true
3792
+ });
3793
+ }
3794
+ const offset = src.inFlight && !src.pendingAppend ? src.pendingOffset : src.offset;
3795
+ return this._sourceLoad({ offset, reason: "refresh", keep: true });
3796
+ }
3797
+ /** The last query sent to `dataSource`, or null. */
3798
+ getQuery() {
3799
+ return this._source?.query || null;
3800
+ }
3801
+ /** What the last answer said about the whole: `{offset, total, hasMore}`. */
3802
+ getPageInfo() {
3803
+ const s = this._source;
3804
+ return s ? { offset: s.offset, total: s.total, hasMore: s.hasMore } : null;
3805
+ }
3806
+ /**
3807
+ * Turn following on or off (`follow`). On, the first page is read again
3808
+ * every `follow.interval` ms while the table is on it and in the page.
3809
+ */
3810
+ setFollow(on) {
3811
+ const next = !!on;
3812
+ if (next === this._following) return;
3813
+ this._following = next;
3814
+ if (next) this._armFollow();
3815
+ else this._disarmFollow();
3816
+ try {
3817
+ this.config.onFollowChange?.(next);
3818
+ } catch (err) {
3819
+ console.error("[DataTable] onFollowChange threw", err);
3820
+ }
3821
+ }
3822
+ isFollowing() {
3823
+ return this._following;
3824
+ }
3825
+ _followInterval() {
3826
+ const f = this.config.follow;
3827
+ const ms = f && typeof f === "object" ? Number(f.interval) : NaN;
3828
+ return Number.isFinite(ms) && ms > 0 ? ms : 1e4;
3829
+ }
3830
+ /** The tick holds the table WEAKLY: a follow timer must never be what keeps
3831
+ * a table nobody holds alive (`keepWhileDetached: true`). */
3832
+ _armFollow() {
3833
+ this._disarmFollow();
3834
+ if (!this._following || !this.config.dataSource || !this._wrapperEl) return;
3835
+ const ref = weakRef(this);
3836
+ const id = setInterval(() => {
3837
+ const t = ref.deref();
3838
+ if (!t || t._followTimer !== id) {
3839
+ clearInterval(id);
3840
+ return;
3841
+ }
3842
+ const src = t._source;
3843
+ if (!t._following || !src || src.inFlight) return;
3844
+ if ((src.offset || 0) !== 0) return;
3845
+ if (!t._wrapperEl?.isConnected) return;
3846
+ if (t._readerIsBusy()) return;
3847
+ t.refresh();
3848
+ }, this._followInterval());
3849
+ this._followTimer = id;
3850
+ }
3851
+ _disarmFollow() {
3852
+ if (this._followTimer) clearInterval(this._followTimer);
3853
+ this._followTimer = null;
3854
+ }
3855
+ /** A TICK WAITS FOR A READER. A refresh redraws every row and every open
3856
+ * detail, so a tick that came while somebody was selecting text in the
3857
+ * table (a stack trace to copy) or working inside an open detail took the
3858
+ * text out from under the selection and the focus out of the detail —
3859
+ * every interval, for as long as they stayed. The tick is skipped
3860
+ * instead, and following resumes on the next one after (the release
3861
+ * review). `refresh()` called by the consumer is not a tick and does not
3862
+ * wait. */
3863
+ _readerIsBusy() {
3864
+ const wrap = this._tableWrapEl;
3865
+ const doc = wrap?.ownerDocument;
3866
+ if (!wrap || !doc) return false;
3867
+ const active = doc.activeElement;
3868
+ if (active && active !== wrap && wrap.contains(active) && active.closest?.(".twm-dt-detail")) return true;
3869
+ try {
3870
+ const sel = doc.defaultView?.getSelection?.();
3871
+ if (sel && !sel.isCollapsed && sel.rangeCount > 0) {
3872
+ for (let i = 0; i < sel.rangeCount; i++) {
3873
+ const r = sel.getRangeAt(i);
3874
+ if (wrap.contains(r.startContainer) || wrap.contains(r.endContainer)) return true;
3875
+ }
3876
+ }
3877
+ } catch (_) {
3878
+ }
3879
+ return false;
3880
+ }
3881
+ // ── the pager (#35) ────────────────────────────────────────────────
3882
+ _usesPager070() {
3883
+ const c = this.config;
3884
+ return !!c.dataSource || !!c.pagerLabels || c.maxJumpOffset != null || typeof c.onPageChange === "function" && c.totalCount == null && typeof c.hasMore === "boolean";
3885
+ }
3886
+ _pagerLabel(name) {
3887
+ const labels = this.config.pagerLabels || {};
3888
+ return labels[name];
3889
+ }
3890
+ _pagerRefusalTooDeep() {
3891
+ const max = this.config.maxJumpOffset;
3892
+ const fn = this._pagerLabel("tooDeep");
3893
+ if (typeof fn === "function") return String(fn(max));
3894
+ if (typeof fn === "string") return fn;
3895
+ return `Past ${Number(max).toLocaleString()} rows. Narrow the filters first.`;
3896
+ }
3897
+ _pagerButton(icon, name, tip, disabled, onClick, refusal = null) {
3898
+ const label = this._pagerLabel(name);
3899
+ const btn = this._createPaginationBtn(
3900
+ icon,
3901
+ typeof label === "string" ? label : tip,
3902
+ disabled || !!refusal,
3903
+ onClick
3904
+ );
3905
+ btn.dataset.pager = name;
3906
+ if (typeof label === "string" && label) {
3907
+ const span = document.createElement("span");
3908
+ span.className = "twm-pagination-btn__label";
3909
+ span.textContent = label;
3910
+ btn.appendChild(span);
3911
+ btn.classList.add("twm-pagination-btn--labelled");
3912
+ btn.style.padding = "1px 6px 1px 3px";
3913
+ btn.style.setProperty("min-width", "max-content", "important");
3914
+ btn.style.flexShrink = "0";
3915
+ }
3916
+ if (refusal) {
3917
+ btn.dataset.refusal = refusal;
3918
+ btn.setAttribute("data-tooltip", refusal);
3919
+ btn.title = refusal;
3920
+ }
3921
+ return btn;
3922
+ }
3923
+ /**
3924
+ * THE PAGE ARITHMETIC, ONCE — read by the pager above the rows (0.7.0) and
3925
+ * by the `paging` strip under them (0.6.2), which each worked it out for
3926
+ * themselves. `held` is the rows drawn on this page; `total` a number or
3927
+ * null (unknown, when `hasMore` says whether there is a next page).
3928
+ *
3929
+ * @returns {{known: boolean, hasPrev: boolean, hasNext: boolean, page: number,
3930
+ * pages: number|null, lastOffset: number|null, lastRefusal: string|null}}
3931
+ */
3932
+ _pageNav({ offset = 0, held = 0, total = null, hasMore = false, pageSize }) {
3933
+ const size = Math.max(1, pageSize | 0);
3934
+ const known = typeof total === "number" && Number.isFinite(total);
3935
+ const lastOffset = known ? Math.floor(Math.max(0, total - 1) / size) * size : null;
3936
+ const maxJump = Number.isFinite(this.config.maxJumpOffset) ? this.config.maxJumpOffset : null;
3937
+ let lastRefusal = null;
3938
+ if (!known) {
3939
+ const words = this._pagerLabel("noLast");
3940
+ lastRefusal = typeof words === "string" ? words : "The total is not known, so there is no last page.";
3941
+ } else if (maxJump !== null && lastOffset > maxJump) {
3942
+ lastRefusal = this._pagerRefusalTooDeep();
3943
+ }
3944
+ return {
3945
+ known,
3946
+ hasPrev: offset > 0,
3947
+ hasNext: known ? offset + held < total : !!hasMore,
3948
+ page: Math.floor(offset / size) + 1,
3949
+ pages: known ? Math.max(1, Math.ceil(total / size)) : null,
3950
+ lastOffset,
3951
+ lastRefusal
3952
+ };
3953
+ }
3954
+ /**
3955
+ * The pager for any mode, with the two things the 0.6 strip could not do:
3956
+ * an UNKNOWN total (no "of N", no last page, Next until a short page) and
3957
+ * a REFUSED deep jump (`maxJumpOffset`). For a known total its words are
3958
+ * the 0.6 strip's.
3959
+ */
3960
+ _createPager070() {
3961
+ const c = this.config;
3962
+ const { rows, pageSize } = c;
3963
+ const source = !!c.dataSource;
3964
+ const server = !source && typeof c.onPageChange === "function";
3965
+ const src = this._source || { offset: 0, total: null, hasMore: false };
3966
+ const offset = source ? src.offset : server ? c.offset || 0 : this._state.offset;
3967
+ let total;
3968
+ let hasMoreKnown;
3969
+ if (source) {
3970
+ total = src.total;
3971
+ hasMoreKnown = src.hasMore;
3972
+ } else if (server) {
3973
+ const unknown = c.totalCount == null && typeof c.hasMore === "boolean";
3974
+ total = unknown ? null : c.totalCount || rows.length;
3975
+ hasMoreKnown = unknown ? c.hasMore : null;
3976
+ } else {
3977
+ total = this._getProcessedRows().length;
3978
+ hasMoreKnown = null;
3979
+ }
3980
+ const known = typeof total === "number" && Number.isFinite(total);
3981
+ const isFiltered = !source && !server && this._state.filters.size > 0;
3982
+ const unfilteredTotal = !source && !server ? rows.length : total;
3983
+ const drawn = source || server ? rows.length : Math.max(0, Math.min(pageSize, total - offset));
3984
+ const pn = this._pageNav({ offset, held: drawn, total, hasMore: hasMoreKnown, pageSize });
3985
+ const { hasPrev, hasNext, page } = pn;
3986
+ const totalPages = pn.pages;
3987
+ if (known && totalPages <= 1 && !isFiltered && !hasPrev) return null;
3988
+ if (!known && !hasPrev && !hasNext) return null;
3989
+ const maxJump = Number.isFinite(c.maxJumpOffset) ? c.maxJumpOffset : null;
3990
+ const go = (newOffset) => {
3991
+ if (source) {
3992
+ this._sourceGoTo(newOffset);
3993
+ return;
3994
+ }
3995
+ if (server) {
3996
+ this._awaitWithSpinner(c.onPageChange(newOffset, pageSize));
3997
+ return;
3998
+ }
3999
+ this._state.offset = newOffset;
4000
+ this.render();
4001
+ };
4002
+ const el = document.createElement("div");
4003
+ el.className = "twm-pagination-controls";
4004
+ if (!known) el.classList.add("twm-pagination-controls--unknown-total");
4005
+ el.style.cssText = "display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;";
4006
+ const info = document.createElement("span");
4007
+ info.className = "twm-pagination-info";
4008
+ info.style.cssText = "color:#aaa; white-space:nowrap;";
4009
+ const from = (offset + 1).toLocaleString();
4010
+ const to = (offset + drawn).toLocaleString();
4011
+ if (drawn === 0) {
4012
+ info.textContent = hasPrev ? `Page ${page} \xB7 no rows` : isFiltered ? `0 of ${unfilteredTotal.toLocaleString()} rows match` : "No rows";
4013
+ } else if (!known) {
4014
+ info.textContent = `Rows ${from}\u2013${to}`;
4015
+ } else if (isFiltered) {
4016
+ info.textContent = `Rows ${from}\u2013${to} of ${total.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
4017
+ } else {
4018
+ info.textContent = `Rows ${from}\u2013${to} of ${total.toLocaleString()}`;
4019
+ }
4020
+ const spacer = document.createElement("div");
4021
+ spacer.style.flex = "1";
4022
+ const firstBtn = this._pagerButton("first_page", "first", "First page", !hasPrev, () => go(0));
4023
+ const prevBtn = this._pagerButton(
4024
+ "chevron_left",
4025
+ "prev",
4026
+ "Previous page",
4027
+ !hasPrev,
4028
+ () => go(Math.max(0, offset - pageSize))
4029
+ );
4030
+ const pageInfo = document.createElement("span");
4031
+ pageInfo.className = "twm-pagination-page";
4032
+ pageInfo.style.cssText = "color:#aaa; font-size:10px; min-width:72px; text-align:center;";
4033
+ pageInfo.textContent = known ? `Page ${page} of ${totalPages.toLocaleString()}` : `Page ${page}`;
4034
+ const nextBtn = this._pagerButton(
4035
+ "chevron_right",
4036
+ "next",
4037
+ "Next page",
4038
+ !hasNext,
4039
+ () => go(offset + pageSize)
4040
+ );
4041
+ const { lastOffset, lastRefusal } = pn;
4042
+ const lastBtn = this._pagerButton(
4043
+ "last_page",
4044
+ "last",
4045
+ "Last page",
4046
+ !hasNext || lastOffset === offset,
4047
+ () => {
4048
+ if (lastOffset !== null) go(lastOffset);
4049
+ },
4050
+ lastRefusal
4051
+ );
4052
+ const jumpInput = document.createElement("input");
4053
+ jumpInput.type = "number";
4054
+ jumpInput.className = "twm-pagination-input";
4055
+ jumpInput.style.cssText = "width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;";
4056
+ jumpInput.min = "1";
4057
+ if (known) jumpInput.max = String(total);
4058
+ jumpInput.placeholder = "Row #";
4059
+ const jumpBtn = this._createPaginationBtn(null, "Go to row", false, () => {
4060
+ const target = Math.floor(Number(jumpInput.value));
4061
+ if (!Number.isFinite(target) || target < 1) return;
4062
+ const row = known ? Math.min(target, Math.max(1, total)) : target;
4063
+ const newOffset = Math.floor((row - 1) / pageSize) * pageSize;
4064
+ if (maxJump !== null && newOffset > maxJump) {
4065
+ const why = this._pagerRefusalTooDeep();
4066
+ jumpInput.setAttribute("aria-invalid", "true");
4067
+ jumpInput.title = why;
4068
+ jumpInput.dataset.refusal = why;
4069
+ return;
4070
+ }
4071
+ jumpInput.removeAttribute("aria-invalid");
4072
+ delete jumpInput.dataset.refusal;
4073
+ if (server && typeof c.onJumpToRow === "function") {
4074
+ c.onJumpToRow(row - 1);
4075
+ return;
4076
+ }
4077
+ go(newOffset);
4078
+ }, "Go");
4079
+ jumpBtn.dataset.pager = "go";
4080
+ el.append(info, spacer, firstBtn, prevBtn, pageInfo, nextBtn, lastBtn, jumpInput, jumpBtn);
4081
+ if (c.showExportButton) {
4082
+ el.appendChild(this._createPaginationBtn(
4083
+ "download",
4084
+ "Download visible rows as CSV",
4085
+ false,
4086
+ () => this.downloadCSV("data.csv")
4087
+ ));
4088
+ }
4089
+ return el;
4090
+ }
4091
+ // ─────────────────────────────────────────────────────────────────
4092
+ // 0.7.0 — filter controls that are not text boxes (#37)
4093
+ // ─────────────────────────────────────────────────────────────────
4094
+ /** The spec for a column, normalised: null for the 0.4 text box. */
4095
+ _filterKindFor(colIdx, header) {
4096
+ const fk = this.config.filterKinds;
4097
+ if (fk == null) return null;
4098
+ let spec;
4099
+ if (typeof fk === "function") {
4100
+ try {
4101
+ spec = fk(colIdx, header);
4102
+ } catch (err) {
4103
+ console.error("[DataTable] filterKinds threw", err);
4104
+ spec = null;
4105
+ }
4106
+ } else {
4107
+ spec = perColumn(fk, colIdx);
4108
+ }
4109
+ if (spec == null || spec === "text" || spec === false) return null;
4110
+ if (typeof spec === "function") return { kind: "custom", create: spec };
4111
+ if (typeof spec === "object") {
4112
+ if (spec.kind === "text") return null;
4113
+ if (spec.kind === "chips") return spec;
4114
+ if (typeof spec.create === "function") return { ...spec, kind: spec.kind || "custom" };
4115
+ }
4116
+ if (spec === "chips") return null;
4117
+ return null;
4118
+ }
4119
+ /** The column's control — built once, then the same element every render. */
4120
+ _filterControlFor(colIdx, header) {
4121
+ const spec = this._filterKindFor(colIdx, header);
4122
+ if (!spec) return null;
4123
+ const have = this._filterControls.get(colIdx);
4124
+ if (have && have.kind === spec.kind && have.specKey === (spec.options || spec.create)) {
4125
+ return have.control;
4126
+ }
4127
+ if (have) this._destroyOneControl(have.control);
4128
+ const value = this._state.filters.get(colIdx) ?? null;
4129
+ const ctx = {
4130
+ column: colIdx,
4131
+ header,
4132
+ value,
4133
+ counts: this._filterCounts.get(colIdx) ?? null,
4134
+ table: this,
4135
+ setValue: (v) => this.setFilter(colIdx, v)
4136
+ };
4137
+ let control = null;
4138
+ try {
4139
+ control = spec.kind === "chips" ? createChipsFilter(ctx, spec) : spec.create(ctx);
4140
+ } catch (err) {
4141
+ console.error("[DataTable] filter control threw while being built", err);
4142
+ control = null;
4143
+ }
4144
+ if (typeof Node !== "undefined" && control instanceof Node) control = { el: control };
4145
+ if (!control || !control.el) return null;
4146
+ control.el.classList?.add("twm-dt-filter-control");
4147
+ this._filterControls.set(colIdx, {
4148
+ kind: spec.kind,
4149
+ specKey: spec.options || spec.create,
4150
+ control,
4151
+ match: typeof control.match === "function" ? control.match : spec.kind === "chips" ? chipsMatch(spec) : null
4152
+ });
4153
+ return control;
4154
+ }
4155
+ /** The client-side rule for a column with a control, or null. */
4156
+ _filterMatcher(colIdx) {
4157
+ if (!this._filterControls.size && this.config.filterKinds == null) return null;
4158
+ const entry = this._filterControls.get(colIdx);
4159
+ if (entry) return entry.match || defaultControlMatch;
4160
+ const spec = this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
4161
+ if (!spec) return null;
4162
+ return spec.kind === "chips" ? chipsMatch(spec) : spec.match || defaultControlMatch;
4163
+ }
4164
+ _isEmptyFilterValue(v) {
4165
+ if (v == null || v === "" || v === false) return true;
4166
+ if (Array.isArray(v)) return v.length === 0;
4167
+ if (typeof v === "object" && !(v instanceof Date)) {
4168
+ return Object.values(v).every((x) => x == null || x === "");
4169
+ }
4170
+ return false;
4171
+ }
4172
+ _destroyOneControl(control) {
4173
+ try {
4174
+ control?.destroy?.();
4175
+ } catch (err) {
4176
+ console.error("[DataTable] filter control destroy threw", err);
4177
+ }
4178
+ control?.el?.remove?.();
4179
+ }
4180
+ _destroyFilterControls() {
4181
+ for (const { control } of this._filterControls.values()) this._destroyOneControl(control);
4182
+ this._filterControls.clear();
4183
+ }
4184
+ _notifyFilterChange() {
4185
+ if (typeof this.config.onFilterChange !== "function") return;
4186
+ try {
4187
+ this.config.onFilterChange(this._filterList());
4188
+ } catch (err) {
4189
+ console.error("[DataTable] onFilterChange threw", err);
4190
+ }
4191
+ }
4192
+ /** The column filters in force: `[{column, header, value}]`. */
4193
+ getFilters() {
4194
+ return this._filterList();
4195
+ }
4196
+ /**
4197
+ * Set one column's filter (`null`, `''` or an empty array clears it). The
4198
+ * value is whatever the column's control works in — text for the 0.4 box.
4199
+ */
4200
+ setFilter(colIdx, value) {
4201
+ const textBox = !this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
4202
+ const v = textBox && value != null && typeof value !== "string" ? String(value) : value;
4203
+ if (textBox) {
4204
+ const filterRow = this._wrapperEl?.querySelector(".twm-data-table__filter-row");
4205
+ const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
4206
+ const input = cell?.querySelector(".twm-data-table__filter-input");
4207
+ if (input) input.value = v || "";
4208
+ }
4209
+ this._onFilterInput(colIdx, v ?? "");
4210
+ }
4211
+ /**
4212
+ * Counts for a column's filter control — chips draw them beside each
4213
+ * option (keyed by the option's value; `''` is "no filter"), a dimmed
4214
+ * chip at zero. `setFilterCounts(colIdx, counts)`, or
4215
+ * `setFilterCounts({[colIdx]: counts})` for several. They are kept, so a
4216
+ * control built later starts with them.
4217
+ */
4218
+ setFilterCounts(colIdx, counts) {
4219
+ if (colIdx && typeof colIdx === "object" && counts === void 0) {
4220
+ for (const [k, c] of Object.entries(colIdx)) this.setFilterCounts(Number(k), c);
4221
+ return;
4222
+ }
4223
+ const col = Number(colIdx);
4224
+ if (!Number.isInteger(col)) return;
4225
+ if (counts == null) this._filterCounts.delete(col);
4226
+ else this._filterCounts.set(col, counts);
4227
+ const entry = this._filterControls.get(col);
4228
+ if (entry) {
4229
+ try {
4230
+ entry.control.setCounts?.(counts ?? null);
4231
+ } catch (err) {
4232
+ console.error("[DataTable] filter control setCounts threw", err);
4233
+ }
4234
+ }
4235
+ }
4236
+ /** What `setFilterCounts` last gave a column, or null. */
4237
+ getFilterCounts(colIdx) {
4238
+ return this._filterCounts.get(Number(colIdx)) ?? null;
4239
+ }
4240
+ // ── focus across a render ──────────────────────────────────────────
4241
+ _captureFocus070(doc) {
4242
+ const active = doc?.activeElement;
4243
+ if (!active || active === doc.body) return null;
4244
+ for (const [colIdx, { control }] of this._filterControls) {
4245
+ if (control.el?.contains?.(active)) {
4246
+ let sel = null;
4247
+ try {
4248
+ if (typeof active.selectionStart === "number") sel = [active.selectionStart, active.selectionEnd];
4249
+ } catch (_) {
4250
+ }
4251
+ return { kind: "control", colIdx, el: active, sel };
4252
+ }
4253
+ }
4254
+ const detailHost = active.closest?.(".twm-dt-detail");
4255
+ if (detailHost && this._tbodyEl?.contains(detailHost)) {
4256
+ const tr = detailHost.closest("tr")?.__detailOf;
4257
+ if (tr && tr.__rowIndex !== void 0) {
4258
+ const path = [];
4259
+ for (let n = active; n && n !== detailHost; n = n.parentElement) {
4260
+ path.unshift(Array.prototype.indexOf.call(n.parentElement.children, n));
4261
+ }
4262
+ let sel = null;
4263
+ try {
4264
+ if (typeof active.selectionStart === "number") sel = [active.selectionStart, active.selectionEnd];
4265
+ } catch (_) {
4266
+ }
4267
+ return { kind: "detail", key: this._rowKey(tr.__row, tr.__rowIndex), path, tag: active.tagName, sel };
4268
+ }
4269
+ }
4270
+ const toggle = active.closest?.(".twm-dt-detail-toggle");
4271
+ if (toggle && this._tbodyEl?.contains(toggle)) {
4272
+ const tr = toggle.closest("tr");
4273
+ if (tr && tr.__rowIndex !== void 0) {
4274
+ return { kind: "toggle", key: this._rowKey(tr.__row, tr.__rowIndex) };
4275
+ }
4276
+ }
4277
+ return null;
4278
+ }
4279
+ _restoreFocus070(f) {
4280
+ try {
4281
+ if (f.kind === "control") {
4282
+ if (!f.el.isConnected) return;
4283
+ f.el.focus({ preventScroll: true });
4284
+ if (f.sel) f.el.setSelectionRange?.(f.sel[0], f.sel[1]);
4285
+ } else if (f.kind === "detail" && f.key !== null) {
4286
+ let n = this._rowElByKey(f.key)?.__detail?.[0]?.querySelector(".twm-dt-detail");
4287
+ for (const i of f.path) n = n?.children[i];
4288
+ if (n && n.tagName === f.tag) {
4289
+ n.focus({ preventScroll: true });
4290
+ if (f.sel) n.setSelectionRange?.(f.sel[0], f.sel[1]);
4291
+ }
4292
+ } else if (f.kind === "toggle" && f.key !== null) {
4293
+ const tr = this._rowElByKey(f.key);
4294
+ tr?.querySelector(":scope > td > .twm-dt-detail-toggle")?.focus({ preventScroll: true });
4295
+ }
4296
+ } catch (_) {
4297
+ }
4298
+ }
4299
+ /** The one row spanning every column that says there are no rows (or why
4300
+ * they could not be read). Under `dataSource` the plain message is drawn
4301
+ * by class, so it is quiet while the first answer is on its way. */
4302
+ _buildEmptyRow(colCount, emptyMessage) {
4303
+ const emptyTr = document.createElement("tr");
4304
+ emptyTr.className = "data-preview-row data-table__empty-row";
4305
+ const emptyTd = document.createElement("td");
4306
+ emptyTd.colSpan = colCount;
4307
+ if (this._error) {
4308
+ emptyTd.className = "twm-data-table__empty-cell twm-data-table__empty-cell--error";
4309
+ emptyTd.setAttribute("role", "alert");
4310
+ this._appendStateContent(emptyTd, this._errorContent());
4311
+ } else if (this.config.emptyState !== void 0) {
4312
+ emptyTd.className = "twm-data-table__empty-cell twm-data-table__empty-cell--state";
4313
+ const state = typeof this.config.emptyState === "function" ? this.config.emptyState() : this.config.emptyState;
4314
+ this._appendStateContent(emptyTd, state);
4315
+ } else if (this.config.dataSource) {
4316
+ emptyTd.className = "twm-data-table__empty-cell twm-data-table__empty-cell--state";
4317
+ emptyTd.textContent = emptyMessage;
4318
+ } else {
4319
+ emptyTd.style.cssText = "text-align:center; font-style:italic; color:#888; padding:16px;";
4320
+ emptyTd.textContent = emptyMessage;
4321
+ }
4322
+ emptyTr.appendChild(emptyTd);
4323
+ return emptyTr;
4324
+ }
4325
+ // ─────────────────────────────────────────────────────────────────
2808
4326
  // 0.5.0 — decoration, states and the typed columns
2809
4327
  // ─────────────────────────────────────────────────────────────────
2810
4328
  /** The class list both halves of the table carry: the 0.4 pair, plus the
@@ -3013,6 +4531,9 @@ var DataTable = class {
3013
4531
  tr.__rowKey = this._rowKey(row, rowIdx);
3014
4532
  }
3015
4533
  this._paintActive(tr);
4534
+ if (this._newKeys) {
4535
+ tr.classList.toggle("twm-dt-row--new", this._newKeys.has(this._rowKey(row, rowIdx)));
4536
+ }
3016
4537
  }
3017
4538
  /** The open mark on one row, from `_activeKey`. */
3018
4539
  _paintActive(tr) {
@@ -3168,6 +4689,21 @@ var DataTable = class {
3168
4689
  const head = labelRow && labelRow.children[i] ? labelRow.children[i].getBoundingClientRect().width / zoom : 0;
3169
4690
  natural[i] = Math.max(body, head);
3170
4691
  }
4692
+ const floors = new Array(cols).fill(0);
4693
+ const filterRow = headerTable.querySelector("thead > tr.twm-data-table__filter-row--controls");
4694
+ if (filterRow) {
4695
+ for (let i = 0; i < cols && i < filterRow.children.length; i++) {
4696
+ const th = filterRow.children[i];
4697
+ if (!th.classList.contains("data-table__filter-cell--control")) continue;
4698
+ const el = th.firstElementChild;
4699
+ if (!el) continue;
4700
+ const cs = getComputedStyle(th);
4701
+ const pad2 = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0) + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
4702
+ floors[i] = Math.ceil(el.getBoundingClientRect().width / zoom + pad2);
4703
+ natural[i] = Math.max(natural[i], floors[i]);
4704
+ }
4705
+ }
4706
+ this._filterFloors = floors;
3171
4707
  for (const tr of sampled) tr.remove();
3172
4708
  headerTable.classList.remove("twm-dt-measuring");
3173
4709
  bodyTable.classList.remove("twm-dt-measuring");
@@ -3325,7 +4861,8 @@ var DataTable = class {
3325
4861
  const widths = this._fitColumnWidths(natural, avail, {
3326
4862
  firstIdx,
3327
4863
  overrides: this._colWidths,
3328
- fit: this.config.columnFit
4864
+ fit: this.config.columnFit,
4865
+ floors: this._filterFloors
3329
4866
  });
3330
4867
  const total = widths.reduce((a, b) => a + b, 0);
3331
4868
  headerRows.forEach((tr) => {
@@ -3395,6 +4932,8 @@ var DataTable = class {
3395
4932
  const n = natural.length;
3396
4933
  const firstIdx = opts.firstIdx ?? 0;
3397
4934
  const overrides = opts.overrides || {};
4935
+ const floors = Array.isArray(opts.floors) ? opts.floors : [];
4936
+ const floorOf = (i) => Math.max(FLOOR, Math.ceil(floors[i] || 0));
3398
4937
  const toContent = opts.fit === "content";
3399
4938
  const desired = new Array(n);
3400
4939
  const pinned = new Array(n).fill(false);
@@ -3435,7 +4974,7 @@ var DataTable = class {
3435
4974
  }
3436
4975
  const budget = avail - protectedSum;
3437
4976
  if (budget < shrinkable.length * FLOOR) {
3438
- for (const i of shrinkable) widths[i] = FLOOR;
4977
+ for (const i of shrinkable) widths[i] = floorOf(i);
3439
4978
  return widths;
3440
4979
  }
3441
4980
  shrinkable.sort((a, b) => desired[a] - desired[b]);
@@ -3450,7 +4989,7 @@ var DataTable = class {
3450
4989
  } else {
3451
4990
  const level = Math.max(FLOOR, fair);
3452
4991
  for (let j = k; j < shrinkable.length; j++) {
3453
- widths[shrinkable[j]] = level;
4992
+ widths[shrinkable[j]] = Math.max(level, floorOf(shrinkable[j]));
3454
4993
  }
3455
4994
  const leftover = remaining - colsLeft * level;
3456
4995
  if (leftover > 0) widths[shrinkable[shrinkable.length - 1]] += leftover;
@@ -3698,6 +5237,7 @@ var DataTable = class {
3698
5237
  const cells = rows ? rows.flatMap((tr) => [...tr.children]) : bodyTable.querySelectorAll("tbody td");
3699
5238
  cells.forEach((td) => {
3700
5239
  if (td.parentElement?.classList.contains("twm-dt-spacer")) return;
5240
+ if (td.closest?.(".twm-dt-detail-row")) return;
3701
5241
  const clipped = td.scrollWidth > td.clientWidth + 1;
3702
5242
  if (clipped) {
3703
5243
  if (!td.title) td.title = td.textContent;
@@ -3750,6 +5290,20 @@ var DataTable = class {
3750
5290
  if (typeof this.config.onRowActivate === "function") {
3751
5291
  this._installActivation(tbody, table);
3752
5292
  }
5293
+ if (this._detailOn()) {
5294
+ const onDetailKey = (ev) => {
5295
+ if (ev.key !== "ArrowRight" && ev.key !== "ArrowLeft") return;
5296
+ if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) return;
5297
+ const toggle = ev.target?.closest?.(".twm-dt-detail-toggle");
5298
+ let tr = null;
5299
+ if (toggle && table.contains(toggle)) tr = toggle.closest("tr");
5300
+ else if (ev.target === table) tr = this._currentRowEl();
5301
+ if (!tr || tr.__rowIndex === void 0) return;
5302
+ if (this._setRowElExpanded(tr, ev.key === "ArrowRight", ev)) ev.preventDefault();
5303
+ };
5304
+ table.addEventListener("keydown", onDetailKey);
5305
+ this._disposers.push(() => table.removeEventListener("keydown", onDetailKey));
5306
+ }
3753
5307
  if (copyable) {
3754
5308
  const handleContextMenu = (event) => {
3755
5309
  const rowEl = event.target.closest("tr");
@@ -3757,6 +5311,10 @@ var DataTable = class {
3757
5311
  this._hideContextMenu();
3758
5312
  return;
3759
5313
  }
5314
+ if (rowEl.closest(".twm-dt-detail-row")) {
5315
+ this._hideContextMenu();
5316
+ return;
5317
+ }
3760
5318
  event.preventDefault();
3761
5319
  event.stopPropagation();
3762
5320
  const idx = rowEl.__rowIndex;
@@ -3992,9 +5550,10 @@ export {
3992
5550
  formatDuration,
3993
5551
  matchDateFilter,
3994
5552
  matchDurationFilter,
5553
+ createChipsFilter,
3995
5554
  PAGING_HARD_LIMIT,
3996
5555
  PAGING_SOFT_LIMIT,
3997
5556
  resolvePaging,
3998
5557
  DataTable
3999
5558
  };
4000
- //# sourceMappingURL=chunk-CPYBQN3Q.js.map
5559
+ //# sourceMappingURL=chunk-JLCJWCRS.js.map