flexdesk 0.6.1 → 0.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flexdesk",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "A tiling window manager and widget set for desktop-class web applications. Zero runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,6 +40,67 @@ const DEFAULT_PAGE_SIZE = 100;
40
40
  /** 0.6.1 — the row statuses `diff` draws. */
41
41
  const DIFF_STATUSES = new Set(['added', 'removed', 'changed', 'same', 'gap']);
42
42
 
43
+ // ── `paging`: a server's rows, in pages or as one scrolling list ────────────
44
+ //
45
+ // THE TWO LIMITS. A table that scrolls holds every row it has loaded, in one
46
+ // array and (through `virtualize`) one scroll range, so how many rows it may
47
+ // hold is a decision rather than an accident of how far somebody scrolled.
48
+ // The SOFT limit is the consumer's (a person's preference, say): a source
49
+ // with more rows than that is shown in pages, whatever was asked for. The HARD
50
+ // limit is the ceiling no soft limit may exceed, and no scrolling table ever
51
+ // holds more rows than it.
52
+
53
+ /** The most rows a scrolling table holds, and the ceiling of every soft limit. */
54
+ export const PAGING_HARD_LIMIT = 100_000;
55
+ /** The soft limit when the consumer names none. */
56
+ export const PAGING_SOFT_LIMIT = 10_000;
57
+
58
+ /**
59
+ * WHICH WAY A TABLE IS SHOWN — pure, so a consumer deciding how to FETCH and
60
+ * the table deciding how to DRAW get the same answer from the same facts.
61
+ *
62
+ * Scroll is allowed only while the source is known to hold no more rows than
63
+ * the soft limit. What is known is the exact `total` when there is one, and
64
+ * otherwise the most the facts prove: a lower bound (`atLeast`, a capped
65
+ * count) or the rows already held plus one when the source says it has more.
66
+ * An unknown total therefore does not force pages until the rows themselves
67
+ * prove the source is larger than the limit.
68
+ *
69
+ * @param {object} [o]
70
+ * @param {'pages'|'scroll'} [o.mode='scroll'] what was asked for
71
+ * @param {number|null} [o.total] the source's exact row count, if known
72
+ * @param {number} [o.atLeast=0] a lower bound on it (a capped count)
73
+ * @param {number} [o.held=0] rows the table holds now
74
+ * @param {boolean} [o.hasMore=false] the source has rows past those held
75
+ * @param {number} [o.softLimit=PAGING_SOFT_LIMIT]
76
+ * @param {number} [o.hardLimit=PAGING_HARD_LIMIT]
77
+ * @returns {{mode: 'pages'|'scroll', requested: 'pages'|'scroll', forced: boolean,
78
+ * softLimit: number, hardLimit: number, known: number, exact: boolean}}
79
+ */
80
+ export function resolvePaging({
81
+ mode = 'scroll', total = null, atLeast = 0, held = 0, hasMore = false,
82
+ softLimit = PAGING_SOFT_LIMIT, hardLimit = PAGING_HARD_LIMIT,
83
+ } = {}) {
84
+ const requested = mode === 'pages' ? 'pages' : 'scroll';
85
+ const hard = Number.isFinite(hardLimit) && hardLimit >= 1
86
+ ? Math.floor(hardLimit) : PAGING_HARD_LIMIT;
87
+ // A soft limit above the hard one is the hard one: nothing the consumer
88
+ // names can let a table hold more than the ceiling.
89
+ const soft = Math.min(hard, Number.isFinite(softLimit) && softLimit >= 1
90
+ ? Math.floor(softLimit) : PAGING_SOFT_LIMIT);
91
+ const exact = Number.isFinite(total) && total >= 0;
92
+ const known = exact ? total
93
+ : Math.max(Number.isFinite(atLeast) ? atLeast : 0, (held || 0) + (hasMore ? 1 : 0));
94
+ const forced = requested === 'scroll' && known > soft;
95
+ return {
96
+ mode: forced ? 'pages' : requested, requested, forced,
97
+ softLimit: soft, hardLimit: hard, known, exact,
98
+ };
99
+ }
100
+
101
+ /** How near the bottom (px) a scrolling table asks for its next rows. */
102
+ const PAGING_LOAD_MORE_PX = 400;
103
+
43
104
  /** 0.5.0 — the column types that sort and filter by a value rather than by
44
105
  * the text drawn for it. */
45
106
  const TYPED_COLUMNS = new Set(['date', 'datetime', 'duration']);
@@ -232,6 +293,15 @@ const AUTOSIZE_MAX_PX = 520;
232
293
  * @property {string|Function|Object|Array} [dateTimeFormat='YYYY-MM-DD HH:mm'] - The same for a 'datetime' column.
233
294
  * @property {'auto'|'clock'|Function|Object|Array} [durationFormat='auto'] - How a 'duration' column (milliseconds) is drawn: '850 ms', '3.2 s', '2m 5s' (auto) or '0:02:05' (clock).
234
295
  * @property {'local'|'UTC'} [dateTimeZone='local'] - The zone typed date columns are read and drawn in.
296
+ *
297
+ * ── `paging`, off unless asked for ────────────────────────────────────────
298
+ * @property {Object} [paging] - A server's rows shown IN PAGES or as ONE SCROLLING LIST, and a strip under the body that says which and lets the reader change it. The consumer holds the rows and fetches them; the table decides which way they are shown (`resolvePaging`), asks for more, and draws the strip. Set it and the old pager (`pagination`) is not drawn. Its keys:
299
+ * `mode` ('pages'|'scroll', default 'scroll') — what was asked for. Scroll is refused, and pages shown, while the source holds more than `softLimit` rows.
300
+ * `pageSize` — rows per page, and per load while scrolling (default `pageSize`).
301
+ * `softLimit` (default 10,000) and `hardLimit` (default 100,000) — see `resolvePaging`; the soft one is clamped to the hard one, and a scrolling table never asks for rows past the hard one.
302
+ * `total` — the source's exact row count, or null. `atLeast` — a lower bound when the exact count is not known (a capped count). `hasMore` — the source has rows past those held.
303
+ * `offset` — in pages, where the first row held stands in the source: the strip's "Rows 1,001–2,000" and `showRowNumbers: 'position'` read it. The rows' indexes (`__rowIndex`, selection, `onRowClick`) stay those of `rows`.
304
+ * `onPage(offset, limit)` — pages: show the page at `offset`. `onLoadMore()` — scroll: called when the body is scrolled within 400px of its end (`loadMorePx`), or does not fill its box, while `hasMore`. `onLoadAll()` — scroll: the strip's *Load all rows* button. `onModeChange(mode)` — the strip's Pages / Scroll switch; without it there is no switch. Each may return a Promise; until it settles no second call is made and its button shows the table is busy.
235
305
  */
236
306
 
237
307
  /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
@@ -299,6 +369,9 @@ export class DataTable {
299
369
  // promise that adding the key changed no existing layout by a
300
370
  // pixel. Only a consumer that asks for 'content' sees anything new.
301
371
  columnFit: 'container',
372
+ // Off unless asked for — see the typedef. With it the old pager is
373
+ // not drawn: the paging strip says the same and more.
374
+ paging: null,
302
375
  // C10. Off unless asked for — see the typedef.
303
376
  virtualize: false,
304
377
  virtualRowHeight: null,
@@ -839,8 +912,9 @@ export class DataTable {
839
912
  this._loading = false;
840
913
 
841
914
  // Pagination (top). `_createPagination` returns null when the
842
- // strip would be uninformative (single page, no active filter).
843
- if (pagination && rows.length > 0) {
915
+ // strip would be uninformative (single page, no active filter). Not
916
+ // with `paging`, whose own strip (under the body) replaces it.
917
+ if (pagination && rows.length > 0 && !this._pagingConfig()) {
844
918
  this._paginationEl = this._createPagination();
845
919
  if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
846
920
  }
@@ -962,6 +1036,13 @@ export class DataTable {
962
1036
  }
963
1037
 
964
1038
  this._tableWrapEl = tableWrap;
1039
+ // `paging`: the strip goes UNDER the body, where the reader's eye is
1040
+ // when they reach the end of what is held.
1041
+ this._pagingEl = null;
1042
+ if (this._pagingConfig()) {
1043
+ this._pagingEl = this._createPagingStrip();
1044
+ if (this._pagingEl) this._wrapperEl.appendChild(this._pagingEl);
1045
+ }
965
1046
  this.container.appendChild(this._wrapperEl);
966
1047
  // 0.6.1 `scrollLock`: THIS render's body box joins the group, and leaves
967
1048
  // it with the next render's cleanup or the dispose.
@@ -990,12 +1071,29 @@ export class DataTable {
990
1071
  this._virtUpdate({ force: true, notify: false });
991
1072
  }
992
1073
 
1074
+ // `paging`, scrolling: ask for the next rows near the end — and once
1075
+ // the layout has settled, when the rows held do not fill the box at all,
1076
+ // since then nothing can ever be scrolled to ask for them.
1077
+ if (this._pagingConfig()) {
1078
+ const onScroll = () => this._pagingMaybeLoadMore();
1079
+ tableWrap.addEventListener('scroll', onScroll, { passive: true });
1080
+ this._disposers.push(() => tableWrap.removeEventListener('scroll', onScroll));
1081
+ requestAnimationFrame(() => {
1082
+ if (this._tableWrapEl === tableWrap) this._pagingMaybeLoadMore();
1083
+ });
1084
+ }
1085
+
993
1086
  // Sync header column widths to body after layout. Two-pass: the
994
1087
  // first frame lets the browser settle natural widths from the
995
1088
  // body's auto layout, then we lock both tables to those widths
996
1089
  // via table-layout:fixed + explicit cell widths.
997
1090
  if (this._headerTableEl && this._tableEl) {
998
- requestAnimationFrame(() => this._syncHeaderWidths());
1091
+ // 0.6.3: and once the widths are fixed the rows may be another
1092
+ // height than they were measured at — so the window measures again.
1093
+ requestAnimationFrame(() => {
1094
+ this._syncHeaderWidths();
1095
+ this._virtRemeasure();
1096
+ });
999
1097
  // Re-sync on container resize so columns stay aligned when
1000
1098
  // the parent flex / grid layout changes (window resize,
1001
1099
  // splitter drag, etc.).
@@ -2046,6 +2144,238 @@ export class DataTable {
2046
2144
  return btn;
2047
2145
  }
2048
2146
 
2147
+ // ── `paging` ────────────────────────────────────────────────────────────
2148
+
2149
+ /** The `paging` config with its defaults, or null when it is not set. */
2150
+ _pagingConfig() {
2151
+ const p = this.config.paging;
2152
+ if (!p || typeof p !== 'object') return null;
2153
+ const size = Number.isFinite(p.pageSize) && p.pageSize >= 1
2154
+ ? Math.floor(p.pageSize) : this.config.pageSize;
2155
+ return {
2156
+ ...p,
2157
+ pageSize: size,
2158
+ offset: Number.isFinite(p.offset) && p.offset > 0 ? Math.floor(p.offset) : 0,
2159
+ // What the table holds of the source. A consumer whose `rows` carry
2160
+ // a row the source does not (a blank line to type into) says so.
2161
+ held: Number.isFinite(p.held) ? p.held : (this.config.rows?.length || 0),
2162
+ hasMore: !!p.hasMore,
2163
+ loadMorePx: Number.isFinite(p.loadMorePx) ? p.loadMorePx : PAGING_LOAD_MORE_PX,
2164
+ };
2165
+ }
2166
+
2167
+ /**
2168
+ * Which way the rows are shown, and why — `resolvePaging` over this
2169
+ * table's facts. `null` without `paging`.
2170
+ */
2171
+ get pagingState() {
2172
+ const p = this._pagingConfig();
2173
+ if (!p) return null;
2174
+ // A page's rows prove the source holds every row before them too, so
2175
+ // what is PROVEN is the offset plus the rows held.
2176
+ return resolvePaging({
2177
+ mode: p.mode, total: p.total, atLeast: p.atLeast, held: p.offset + p.held,
2178
+ hasMore: p.hasMore, softLimit: p.softLimit, hardLimit: p.hardLimit,
2179
+ });
2180
+ }
2181
+
2182
+ /**
2183
+ * Change some `paging` facts — a count that arrived, a mode — and redraw
2184
+ * the STRIP only. The rows are not touched: a consumer with new rows
2185
+ * renders the table as it always has.
2186
+ */
2187
+ setPaging(patch = {}) {
2188
+ if (!this.config.paging) return;
2189
+ this.config.paging = { ...this.config.paging, ...patch };
2190
+ this._refreshPagingStrip();
2191
+ this._pagingMaybeLoadMore();
2192
+ }
2193
+
2194
+ _refreshPagingStrip() {
2195
+ if (!this._wrapperEl) return;
2196
+ const next = this._pagingConfig() ? this._createPagingStrip() : null;
2197
+ const prev = this._pagingEl;
2198
+ if (prev && prev.parentNode === this._wrapperEl) {
2199
+ if (next) prev.replaceWith(next); else prev.remove();
2200
+ } else if (next) {
2201
+ this._wrapperEl.appendChild(next);
2202
+ }
2203
+ this._pagingEl = next;
2204
+ }
2205
+
2206
+ /**
2207
+ * Call one of the consumer's paging callbacks. A Promise holds every other
2208
+ * paging call back until it settles, and the strip shows the table busy —
2209
+ * a second `onLoadMore` fired by the next scroll event would fetch the same
2210
+ * page twice.
2211
+ */
2212
+ _pagingCall(kind, fn, ...args) {
2213
+ if (typeof fn !== 'function' || this._pagingBusy) return;
2214
+ let result;
2215
+ try {
2216
+ result = fn(...args);
2217
+ } catch (err) {
2218
+ console.error(`[DataTable] paging ${kind} threw`, err);
2219
+ return;
2220
+ }
2221
+ if (result && typeof result.then === 'function') {
2222
+ this._pagingBusy = kind;
2223
+ this._refreshPagingStrip();
2224
+ const settle = () => {
2225
+ if (this._pagingBusy !== kind) return;
2226
+ this._pagingBusy = null;
2227
+ if (this._pagingEl?.isConnected) this._refreshPagingStrip();
2228
+ };
2229
+ result.then(settle, (err) => {
2230
+ console.error(`[DataTable] paging ${kind} failed`, err);
2231
+ settle();
2232
+ });
2233
+ }
2234
+ }
2235
+
2236
+ /**
2237
+ * Scrolling: ask for the next rows when the body is near its end — or does
2238
+ * not fill its box, which no scroll can ever change. Never past the hard
2239
+ * limit, never in pages, never with no layout (a hidden table's box is 0px
2240
+ * tall, and "0 ≤ 0" would ask for the whole source).
2241
+ */
2242
+ _pagingMaybeLoadMore() {
2243
+ const p = this._pagingConfig();
2244
+ if (!p || !p.hasMore || this._pagingBusy) return;
2245
+ const state = this.pagingState;
2246
+ if (state.mode !== 'scroll' || p.held >= state.hardLimit) return;
2247
+ const wrap = this._tableWrapEl;
2248
+ if (!wrap?.isConnected || !(wrap.clientHeight > 0)) return;
2249
+ const remaining = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;
2250
+ if (remaining >= p.loadMorePx) return;
2251
+ this._pagingCall('more', p.onLoadMore);
2252
+ }
2253
+
2254
+ /** The strip under the body, or null when it would say nothing. */
2255
+ _createPagingStrip() {
2256
+ const p = this._pagingConfig();
2257
+ const state = this.pagingState;
2258
+ const { mode, forced, exact, known } = state;
2259
+ const busy = this._pagingBusy;
2260
+ const n = (x) => Number(x).toLocaleString();
2261
+ const pageSize = p.pageSize;
2262
+ const held = p.held;
2263
+ const total = exact ? p.total : null;
2264
+
2265
+ const morePages = p.offset > 0 || p.hasMore || (exact && total > held);
2266
+ if (!forced && mode === 'scroll' && !p.hasMore && held <= pageSize) return null;
2267
+ if (!forced && mode === 'pages' && !morePages) return null;
2268
+
2269
+ const el = document.createElement('div');
2270
+ el.className = `twm-dt-paging twm-dt-paging--${mode}`;
2271
+ el.setAttribute('role', 'navigation');
2272
+ el.setAttribute('aria-label', 'Rows');
2273
+ if (busy) el.classList.add('twm-dt-paging--busy');
2274
+
2275
+ const info = document.createElement('span');
2276
+ info.className = 'twm-dt-paging__info';
2277
+ el.appendChild(info);
2278
+
2279
+ if (mode === 'pages') {
2280
+ const from = held ? p.offset + 1 : p.offset;
2281
+ const to = p.offset + held;
2282
+ let of = '';
2283
+ if (exact) of = ` of ${n(total)}`;
2284
+ else if (known > to) of = ` of more than ${n(known - 1)}`;
2285
+ info.textContent = held ? `Rows ${n(from)}–${n(to)}${of}` : 'No rows on this page';
2286
+
2287
+ const hasPrev = p.offset > 0;
2288
+ const hasNext = exact ? p.offset + held < total : p.hasMore;
2289
+ const lastOffset = exact && total > 0
2290
+ ? Math.floor((total - 1) / pageSize) * pageSize : null;
2291
+ // A disabled button's click is ignored here too: a browser sends none,
2292
+ // but a synthetic one (a test, a script) still reaches the listener.
2293
+ const go = (offset) => (ev) => {
2294
+ if (!ev?.currentTarget?.disabled) this._pagingCall('page', p.onPage, offset, pageSize);
2295
+ };
2296
+ const nav = document.createElement('span');
2297
+ nav.className = 'twm-dt-paging__nav';
2298
+ const off = !!busy || typeof p.onPage !== 'function';
2299
+ nav.append(
2300
+ this._createPaginationBtn('first_page', 'First page', off || !hasPrev, go(0)),
2301
+ this._createPaginationBtn('chevron_left', 'Previous page', off || !hasPrev,
2302
+ go(Math.max(0, p.offset - pageSize))),
2303
+ );
2304
+ const page = document.createElement('span');
2305
+ page.className = 'twm-dt-paging__page';
2306
+ const current = Math.floor(p.offset / pageSize) + 1;
2307
+ page.textContent = exact
2308
+ ? `Page ${n(current)} of ${n(Math.max(1, Math.ceil(total / pageSize)))}`
2309
+ : `Page ${n(current)}`;
2310
+ nav.append(
2311
+ page,
2312
+ this._createPaginationBtn('chevron_right', 'Next page', off || !hasNext,
2313
+ go(p.offset + pageSize)),
2314
+ // The last page needs the exact count: without it there is no
2315
+ // offset to go to, and the button says so by being off.
2316
+ this._createPaginationBtn('last_page',
2317
+ lastOffset === null ? 'Last page (the total is not known)' : 'Last page',
2318
+ off || !hasNext || lastOffset === null, go(lastOffset)),
2319
+ );
2320
+ el.appendChild(nav);
2321
+ } else {
2322
+ if (exact && total > held) info.textContent = `${n(held)} of ${n(total)} rows`;
2323
+ else if (p.hasMore) info.textContent = `${n(held)} rows, more to come`;
2324
+ else info.textContent = `${n(held)} rows`;
2325
+ if (p.hasMore && typeof p.onLoadAll === 'function') {
2326
+ const all = document.createElement('button');
2327
+ all.type = 'button';
2328
+ all.className = 'twm-dt-paging__all';
2329
+ all.textContent = busy === 'all' ? 'Loading…'
2330
+ : exact ? `Load all ${n(total)} rows` : 'Load all rows';
2331
+ all.disabled = !!busy;
2332
+ all.addEventListener('click', () => {
2333
+ if (!all.disabled) this._pagingCall('all', p.onLoadAll);
2334
+ });
2335
+ el.appendChild(all);
2336
+ }
2337
+ }
2338
+
2339
+ const spacer = document.createElement('span');
2340
+ spacer.className = 'twm-dt-paging__spacer';
2341
+ el.appendChild(spacer);
2342
+
2343
+ // WHY IT IS PAGED, said on the strip — a disabled Scroll alone reads as
2344
+ // a switch that does not work.
2345
+ const why = forced
2346
+ ? `More than ${n(state.softLimit)} rows, so they are shown in pages.` : null;
2347
+ if (why) {
2348
+ const note = document.createElement('span');
2349
+ note.className = 'twm-dt-paging__note';
2350
+ note.textContent = why;
2351
+ el.appendChild(note);
2352
+ }
2353
+
2354
+ if (typeof p.onModeChange === 'function') {
2355
+ const group = document.createElement('span');
2356
+ group.className = 'twm-dt-paging__modes';
2357
+ group.setAttribute('role', 'group');
2358
+ group.setAttribute('aria-label', 'Show rows');
2359
+ for (const [value, label] of [['pages', 'Pages'], ['scroll', 'Scroll']]) {
2360
+ const b = document.createElement('button');
2361
+ b.type = 'button';
2362
+ b.className = 'twm-dt-paging__mode';
2363
+ b.dataset.mode = value;
2364
+ b.textContent = label;
2365
+ const pressed = mode === value;
2366
+ b.setAttribute('aria-pressed', String(pressed));
2367
+ b.disabled = !!busy || (value === 'scroll' && forced);
2368
+ if (value === 'scroll' && forced) b.title = why;
2369
+ b.addEventListener('click', () => {
2370
+ if (!b.disabled && mode !== value) this._pagingCall('mode', p.onModeChange, value);
2371
+ });
2372
+ group.appendChild(b);
2373
+ }
2374
+ el.appendChild(group);
2375
+ }
2376
+ return el;
2377
+ }
2378
+
2049
2379
  _createTable() {
2050
2380
  const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
2051
2381
  const { sortColumn, sortAscending } = this._state;
@@ -2058,7 +2388,10 @@ export class DataTable {
2058
2388
  // NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
2059
2389
  // way, so a table built with `pagination: false` silently showed its
2060
2390
  // first hundred rows and offered no control to reach the rest.
2061
- const pageRows = isServerSide || !this.config.pagination
2391
+ // And `paging` rows ARE the page (or everything scrolled to so far):
2392
+ // the consumer fetched them, and slicing them again would draw a
2393
+ // hundred of a thousand.
2394
+ const pageRows = isServerSide || !this.config.pagination || this._pagingConfig()
2062
2395
  ? processedRows : processedRows.slice(offset, offset + pageSize);
2063
2396
 
2064
2397
  const table = document.createElement('table');
@@ -2156,6 +2489,10 @@ export class DataTable {
2156
2489
  // stands in the order SHOWN (the page's first row on a later page).
2157
2490
  this._positionBase = isServerSide ? (configOffset || 0)
2158
2491
  : (this.config.pagination ? offset : 0);
2492
+ // `paging`: a page's rows stand at its offset in the SOURCE, which only
2493
+ // the numbering reads — their indexes stay those of `rows`.
2494
+ const paged = this._pagingConfig();
2495
+ if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
2159
2496
 
2160
2497
  // Body
2161
2498
  const tbody = document.createElement('tbody');
@@ -2363,26 +2700,52 @@ export class DataTable {
2363
2700
  return lo;
2364
2701
  }
2365
2702
 
2366
- /** Measure one real row. Client pixels, so the zoom is divided back out —
2367
- * `_measureNaturalWidths` explains why — and NOT `offsetHeight`, which is
2368
- * rounded: 21 for a 21.19px row is a 1,100px error over 6,000 rows. */
2703
+ /**
2704
+ * Measure the rows that are DRAWN — the median of up to 64 of them. Client
2705
+ * pixels, so the zoom is divided back out (`_measureNaturalWidths` explains
2706
+ * why), and NOT `offsetHeight`, which is rounded: 21 for a 21.19px row is a
2707
+ * 1,100px error over 6,000 rows.
2708
+ *
2709
+ * 0.6.3: IT USED TO MEASURE ONE ROW, ONCE, AT RENDER, and trust it for the
2710
+ * life of the table. A row measured at a bad moment — a box not yet laid
2711
+ * out at its width, a font not yet loaded — set every row's height: a
2712
+ * Tables grid drawing 22.8px rows was found holding a 134px estimate, so the
2713
+ * window took a 650px viewport for five rows, drew rows 1–35 and left the
2714
+ * rest of the box blank spacer while the reader scrolled. Now it is asked
2715
+ * again on every window update (`_virtUpdate`), on a resize and once the
2716
+ * column widths are fixed, and corrects itself the first time real rows
2717
+ * are on screen. The first measure is exact; after that a change has to be
2718
+ * larger than half a pixel, so rows of mixed heights cannot make the
2719
+ * offsets move under the reader on every scroll.
2720
+ *
2721
+ * @returns {boolean} whether the heights changed (the offsets are recomputed).
2722
+ */
2369
2723
  _virtMeasure() {
2370
2724
  const v = this._virt;
2371
2725
  if (!v) return false;
2726
+ const zoom = this._tableEl?.currentCSSZoom || 1;
2727
+ const heights = [];
2372
2728
  for (const tr of v.map.values()) {
2373
2729
  if (!tr.isConnected || tr.hidden) continue;
2374
- const zoom = this._tableEl?.currentCSSZoom || 1;
2375
2730
  const h = tr.getBoundingClientRect().height / zoom;
2376
- if (!(h > 0)) return false;
2377
- if (Math.abs(h - v.base) > 0.01) {
2378
- v.base = h;
2379
- this._virtBase = h;
2380
- this._virtComputeOffsets();
2381
- return true;
2382
- }
2383
- return false;
2384
- }
2385
- return false;
2731
+ if (h > 0) heights.push(h);
2732
+ if (heights.length >= 64) break;
2733
+ }
2734
+ if (!heights.length) return false;
2735
+ heights.sort((a, b) => a - b);
2736
+ const h = heights[heights.length >> 1];
2737
+ const tolerance = v.measured ? 0.5 : 0.01;
2738
+ v.measured = true;
2739
+ if (Math.abs(h - v.base) <= tolerance) return false;
2740
+ v.base = h;
2741
+ this._virtBase = h;
2742
+ this._virtComputeOffsets();
2743
+ return true;
2744
+ }
2745
+
2746
+ /** Measure again, and redraw the window when the heights moved. */
2747
+ _virtRemeasure() {
2748
+ if (this._virtMeasure()) this._virtUpdate({ force: true });
2386
2749
  }
2387
2750
 
2388
2751
  /**
@@ -2402,9 +2765,12 @@ export class DataTable {
2402
2765
  * @param {number} [o.viewport] the viewport height, likewise.
2403
2766
  * @returns {HTMLElement[]} the rows added.
2404
2767
  */
2405
- _virtUpdate({ force = false, notify = true, top = null, viewport = null } = {}) {
2768
+ _virtUpdate({ force = false, notify = true, top = null, viewport = null, measure = true } = {}) {
2406
2769
  const v = this._virt;
2407
2770
  if (!v || !v.offsets) return [];
2771
+ // The rows on screen say how tall a row is — before the window is
2772
+ // placed, so a stale estimate is not used to place it (`_virtMeasure`).
2773
+ if (measure && this._virtMeasure()) force = true;
2408
2774
  const n = v.rows.length;
2409
2775
  const wrap = this._tableWrapEl;
2410
2776
  const view = viewport || wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
@@ -2432,6 +2798,13 @@ export class DataTable {
2432
2798
  console.error('[DataTable] onRowsRendered threw', err);
2433
2799
  }
2434
2800
  }
2801
+ // And the rows just drawn: if they disagree with the estimate the
2802
+ // window was placed by, place it once more by what they say.
2803
+ if (measure && added.length && this._virtMeasure()) {
2804
+ return added.concat(this._virtUpdate({
2805
+ force: true, notify, top, viewport, measure: false,
2806
+ }));
2807
+ }
2435
2808
  }
2436
2809
  return added;
2437
2810
  }