flexdesk 0.6.0 → 0.6.2

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.0",
3
+ "version": "0.6.2",
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": {
@@ -37,6 +37,70 @@ export {
37
37
 
38
38
  const DEFAULT_PAGE_SIZE = 100;
39
39
 
40
+ /** 0.6.1 — the row statuses `diff` draws. */
41
+ const DIFF_STATUSES = new Set(['added', 'removed', 'changed', 'same', 'gap']);
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
+
40
104
  /** 0.5.0 — the column types that sort and filter by a value rather than by
41
105
  * the text drawn for it. */
42
106
  const TYPED_COLUMNS = new Set(['date', 'datetime', 'duration']);
@@ -218,6 +282,8 @@ const AUTOSIZE_MAX_PX = 520;
218
282
  * @property {Function} [rowClass] - `(row, rowIdx) => string|string[]|null`. Classes for the row's `<tr>`.
219
283
  * @property {Function} [rowAttrs] - `(row, rowIdx) => {name: value}`. Attributes for the row's `<tr>` (`null`/`false` leaves one off, `true` sets it empty).
220
284
  * @property {Function} [cellClass] - `(value, colIdx, row, rowIdx) => string|string[]|null`. Classes for a cell, after its type class.
285
+ * @property {Function} [diff] - 0.6.1. `(row, rowIdx) => {status, cells?}|null`. One side of a COMPARISON: `status` is 'added', 'removed', 'changed', 'same' or 'gap' and draws the row `twm-dt-diff twm-dt-diff--<status>`; `cells` (an array or Set of column indices) marks the cells that differ `twm-dt-diff__cell--changed`. A 'gap' row is the place a row holds on the OTHER side: its cells are drawn empty, whatever its values and `renderCell`, so two tables drawn from one aligned row list keep their rows level. `null` (or an unknown status) draws the row as without `diff`.
286
+ * @property {object} [scrollLock] - 0.6.1. A group from `createScrollLock()`: the table's body box joins it on every render and leaves it on the next render and on dispose, so tables in the same group scroll as one.
221
287
  * @property {Function} [rowIcon] - `(row, rowIdx) => string|{icon, title?, tone?}|null`. A Material Symbols icon at the start of the row's first cell; `tone` adds `twm-dt-row-icon--<tone>`. Drawn by CSS, so it is in neither the cell's text, its tooltip nor a copy.
222
288
  * @property {string|Node|Function} [emptyState] - What the box says when there are no rows: text, a node, or a function returning either (called on each render). Replaces `emptyMessage`, and is hidden while `setLoading(true)` — an empty table that is still loading is not empty.
223
289
  * @property {string} [nullDisplay] - Draw `null`/`undefined` as this text (and copy it so), with `twm-dt-cell--null` on the cell, instead of '-'.
@@ -227,6 +293,15 @@ const AUTOSIZE_MAX_PX = 520;
227
293
  * @property {string|Function|Object|Array} [dateTimeFormat='YYYY-MM-DD HH:mm'] - The same for a 'datetime' column.
228
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).
229
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.
230
305
  */
231
306
 
232
307
  /** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
@@ -294,6 +369,9 @@ export class DataTable {
294
369
  // promise that adding the key changed no existing layout by a
295
370
  // pixel. Only a consumer that asks for 'content' sees anything new.
296
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,
297
375
  // C10. Off unless asked for — see the typedef.
298
376
  virtualize: false,
299
377
  virtualRowHeight: null,
@@ -313,6 +391,8 @@ export class DataTable {
313
391
  rowClass: null,
314
392
  rowAttrs: null,
315
393
  cellClass: null,
394
+ diff: null,
395
+ scrollLock: null,
316
396
  rowIcon: null,
317
397
  emptyState: undefined,
318
398
  nullDisplay: null,
@@ -832,8 +912,9 @@ export class DataTable {
832
912
  this._loading = false;
833
913
 
834
914
  // Pagination (top). `_createPagination` returns null when the
835
- // strip would be uninformative (single page, no active filter).
836
- 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()) {
837
918
  this._paginationEl = this._createPagination();
838
919
  if (this._paginationEl) this._wrapperEl.appendChild(this._paginationEl);
839
920
  }
@@ -955,7 +1036,19 @@ export class DataTable {
955
1036
  }
956
1037
 
957
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
+ }
958
1046
  this.container.appendChild(this._wrapperEl);
1047
+ // 0.6.1 `scrollLock`: THIS render's body box joins the group, and leaves
1048
+ // it with the next render's cleanup or the dispose.
1049
+ if (this.config.scrollLock && typeof this.config.scrollLock.attach === 'function') {
1050
+ this._disposers.push(this.config.scrollLock.attach(tableWrap));
1051
+ }
959
1052
  // 0.5.0. Watched from here, so a table rendered again after an automatic
960
1053
  // dispose (a `setData` into a re-attached host) is watched again.
961
1054
  if (this.config.autoDispose) autoDisposeWatch(this);
@@ -978,6 +1071,18 @@ export class DataTable {
978
1071
  this._virtUpdate({ force: true, notify: false });
979
1072
  }
980
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
+
981
1086
  // Sync header column widths to body after layout. Two-pass: the
982
1087
  // first frame lets the browser settle natural widths from the
983
1088
  // body's auto layout, then we lock both tables to those widths
@@ -2034,6 +2139,238 @@ export class DataTable {
2034
2139
  return btn;
2035
2140
  }
2036
2141
 
2142
+ // ── `paging` ────────────────────────────────────────────────────────────
2143
+
2144
+ /** The `paging` config with its defaults, or null when it is not set. */
2145
+ _pagingConfig() {
2146
+ const p = this.config.paging;
2147
+ if (!p || typeof p !== 'object') return null;
2148
+ const size = Number.isFinite(p.pageSize) && p.pageSize >= 1
2149
+ ? Math.floor(p.pageSize) : this.config.pageSize;
2150
+ return {
2151
+ ...p,
2152
+ pageSize: size,
2153
+ offset: Number.isFinite(p.offset) && p.offset > 0 ? Math.floor(p.offset) : 0,
2154
+ // What the table holds of the source. A consumer whose `rows` carry
2155
+ // a row the source does not (a blank line to type into) says so.
2156
+ held: Number.isFinite(p.held) ? p.held : (this.config.rows?.length || 0),
2157
+ hasMore: !!p.hasMore,
2158
+ loadMorePx: Number.isFinite(p.loadMorePx) ? p.loadMorePx : PAGING_LOAD_MORE_PX,
2159
+ };
2160
+ }
2161
+
2162
+ /**
2163
+ * Which way the rows are shown, and why — `resolvePaging` over this
2164
+ * table's facts. `null` without `paging`.
2165
+ */
2166
+ get pagingState() {
2167
+ const p = this._pagingConfig();
2168
+ if (!p) return null;
2169
+ // A page's rows prove the source holds every row before them too, so
2170
+ // what is PROVEN is the offset plus the rows held.
2171
+ return resolvePaging({
2172
+ mode: p.mode, total: p.total, atLeast: p.atLeast, held: p.offset + p.held,
2173
+ hasMore: p.hasMore, softLimit: p.softLimit, hardLimit: p.hardLimit,
2174
+ });
2175
+ }
2176
+
2177
+ /**
2178
+ * Change some `paging` facts — a count that arrived, a mode — and redraw
2179
+ * the STRIP only. The rows are not touched: a consumer with new rows
2180
+ * renders the table as it always has.
2181
+ */
2182
+ setPaging(patch = {}) {
2183
+ if (!this.config.paging) return;
2184
+ this.config.paging = { ...this.config.paging, ...patch };
2185
+ this._refreshPagingStrip();
2186
+ this._pagingMaybeLoadMore();
2187
+ }
2188
+
2189
+ _refreshPagingStrip() {
2190
+ if (!this._wrapperEl) return;
2191
+ const next = this._pagingConfig() ? this._createPagingStrip() : null;
2192
+ const prev = this._pagingEl;
2193
+ if (prev && prev.parentNode === this._wrapperEl) {
2194
+ if (next) prev.replaceWith(next); else prev.remove();
2195
+ } else if (next) {
2196
+ this._wrapperEl.appendChild(next);
2197
+ }
2198
+ this._pagingEl = next;
2199
+ }
2200
+
2201
+ /**
2202
+ * Call one of the consumer's paging callbacks. A Promise holds every other
2203
+ * paging call back until it settles, and the strip shows the table busy —
2204
+ * a second `onLoadMore` fired by the next scroll event would fetch the same
2205
+ * page twice.
2206
+ */
2207
+ _pagingCall(kind, fn, ...args) {
2208
+ if (typeof fn !== 'function' || this._pagingBusy) return;
2209
+ let result;
2210
+ try {
2211
+ result = fn(...args);
2212
+ } catch (err) {
2213
+ console.error(`[DataTable] paging ${kind} threw`, err);
2214
+ return;
2215
+ }
2216
+ if (result && typeof result.then === 'function') {
2217
+ this._pagingBusy = kind;
2218
+ this._refreshPagingStrip();
2219
+ const settle = () => {
2220
+ if (this._pagingBusy !== kind) return;
2221
+ this._pagingBusy = null;
2222
+ if (this._pagingEl?.isConnected) this._refreshPagingStrip();
2223
+ };
2224
+ result.then(settle, (err) => {
2225
+ console.error(`[DataTable] paging ${kind} failed`, err);
2226
+ settle();
2227
+ });
2228
+ }
2229
+ }
2230
+
2231
+ /**
2232
+ * Scrolling: ask for the next rows when the body is near its end — or does
2233
+ * not fill its box, which no scroll can ever change. Never past the hard
2234
+ * limit, never in pages, never with no layout (a hidden table's box is 0px
2235
+ * tall, and "0 ≤ 0" would ask for the whole source).
2236
+ */
2237
+ _pagingMaybeLoadMore() {
2238
+ const p = this._pagingConfig();
2239
+ if (!p || !p.hasMore || this._pagingBusy) return;
2240
+ const state = this.pagingState;
2241
+ if (state.mode !== 'scroll' || p.held >= state.hardLimit) return;
2242
+ const wrap = this._tableWrapEl;
2243
+ if (!wrap?.isConnected || !(wrap.clientHeight > 0)) return;
2244
+ const remaining = wrap.scrollHeight - wrap.scrollTop - wrap.clientHeight;
2245
+ if (remaining >= p.loadMorePx) return;
2246
+ this._pagingCall('more', p.onLoadMore);
2247
+ }
2248
+
2249
+ /** The strip under the body, or null when it would say nothing. */
2250
+ _createPagingStrip() {
2251
+ const p = this._pagingConfig();
2252
+ const state = this.pagingState;
2253
+ const { mode, forced, exact, known } = state;
2254
+ const busy = this._pagingBusy;
2255
+ const n = (x) => Number(x).toLocaleString();
2256
+ const pageSize = p.pageSize;
2257
+ const held = p.held;
2258
+ const total = exact ? p.total : null;
2259
+
2260
+ const morePages = p.offset > 0 || p.hasMore || (exact && total > held);
2261
+ if (!forced && mode === 'scroll' && !p.hasMore && held <= pageSize) return null;
2262
+ if (!forced && mode === 'pages' && !morePages) return null;
2263
+
2264
+ const el = document.createElement('div');
2265
+ el.className = `twm-dt-paging twm-dt-paging--${mode}`;
2266
+ el.setAttribute('role', 'navigation');
2267
+ el.setAttribute('aria-label', 'Rows');
2268
+ if (busy) el.classList.add('twm-dt-paging--busy');
2269
+
2270
+ const info = document.createElement('span');
2271
+ info.className = 'twm-dt-paging__info';
2272
+ el.appendChild(info);
2273
+
2274
+ if (mode === 'pages') {
2275
+ const from = held ? p.offset + 1 : p.offset;
2276
+ const to = p.offset + held;
2277
+ let of = '';
2278
+ if (exact) of = ` of ${n(total)}`;
2279
+ else if (known > to) of = ` of more than ${n(known - 1)}`;
2280
+ info.textContent = held ? `Rows ${n(from)}–${n(to)}${of}` : 'No rows on this page';
2281
+
2282
+ const hasPrev = p.offset > 0;
2283
+ const hasNext = exact ? p.offset + held < total : p.hasMore;
2284
+ const lastOffset = exact && total > 0
2285
+ ? Math.floor((total - 1) / pageSize) * pageSize : null;
2286
+ // A disabled button's click is ignored here too: a browser sends none,
2287
+ // but a synthetic one (a test, a script) still reaches the listener.
2288
+ const go = (offset) => (ev) => {
2289
+ if (!ev?.currentTarget?.disabled) this._pagingCall('page', p.onPage, offset, pageSize);
2290
+ };
2291
+ const nav = document.createElement('span');
2292
+ nav.className = 'twm-dt-paging__nav';
2293
+ const off = !!busy || typeof p.onPage !== 'function';
2294
+ nav.append(
2295
+ this._createPaginationBtn('first_page', 'First page', off || !hasPrev, go(0)),
2296
+ this._createPaginationBtn('chevron_left', 'Previous page', off || !hasPrev,
2297
+ go(Math.max(0, p.offset - pageSize))),
2298
+ );
2299
+ const page = document.createElement('span');
2300
+ page.className = 'twm-dt-paging__page';
2301
+ const current = Math.floor(p.offset / pageSize) + 1;
2302
+ page.textContent = exact
2303
+ ? `Page ${n(current)} of ${n(Math.max(1, Math.ceil(total / pageSize)))}`
2304
+ : `Page ${n(current)}`;
2305
+ nav.append(
2306
+ page,
2307
+ this._createPaginationBtn('chevron_right', 'Next page', off || !hasNext,
2308
+ go(p.offset + pageSize)),
2309
+ // The last page needs the exact count: without it there is no
2310
+ // offset to go to, and the button says so by being off.
2311
+ this._createPaginationBtn('last_page',
2312
+ lastOffset === null ? 'Last page (the total is not known)' : 'Last page',
2313
+ off || !hasNext || lastOffset === null, go(lastOffset)),
2314
+ );
2315
+ el.appendChild(nav);
2316
+ } else {
2317
+ if (exact && total > held) info.textContent = `${n(held)} of ${n(total)} rows`;
2318
+ else if (p.hasMore) info.textContent = `${n(held)} rows, more to come`;
2319
+ else info.textContent = `${n(held)} rows`;
2320
+ if (p.hasMore && typeof p.onLoadAll === 'function') {
2321
+ const all = document.createElement('button');
2322
+ all.type = 'button';
2323
+ all.className = 'twm-dt-paging__all';
2324
+ all.textContent = busy === 'all' ? 'Loading…'
2325
+ : exact ? `Load all ${n(total)} rows` : 'Load all rows';
2326
+ all.disabled = !!busy;
2327
+ all.addEventListener('click', () => {
2328
+ if (!all.disabled) this._pagingCall('all', p.onLoadAll);
2329
+ });
2330
+ el.appendChild(all);
2331
+ }
2332
+ }
2333
+
2334
+ const spacer = document.createElement('span');
2335
+ spacer.className = 'twm-dt-paging__spacer';
2336
+ el.appendChild(spacer);
2337
+
2338
+ // WHY IT IS PAGED, said on the strip — a disabled Scroll alone reads as
2339
+ // a switch that does not work.
2340
+ const why = forced
2341
+ ? `More than ${n(state.softLimit)} rows, so they are shown in pages.` : null;
2342
+ if (why) {
2343
+ const note = document.createElement('span');
2344
+ note.className = 'twm-dt-paging__note';
2345
+ note.textContent = why;
2346
+ el.appendChild(note);
2347
+ }
2348
+
2349
+ if (typeof p.onModeChange === 'function') {
2350
+ const group = document.createElement('span');
2351
+ group.className = 'twm-dt-paging__modes';
2352
+ group.setAttribute('role', 'group');
2353
+ group.setAttribute('aria-label', 'Show rows');
2354
+ for (const [value, label] of [['pages', 'Pages'], ['scroll', 'Scroll']]) {
2355
+ const b = document.createElement('button');
2356
+ b.type = 'button';
2357
+ b.className = 'twm-dt-paging__mode';
2358
+ b.dataset.mode = value;
2359
+ b.textContent = label;
2360
+ const pressed = mode === value;
2361
+ b.setAttribute('aria-pressed', String(pressed));
2362
+ b.disabled = !!busy || (value === 'scroll' && forced);
2363
+ if (value === 'scroll' && forced) b.title = why;
2364
+ b.addEventListener('click', () => {
2365
+ if (!b.disabled && mode !== value) this._pagingCall('mode', p.onModeChange, value);
2366
+ });
2367
+ group.appendChild(b);
2368
+ }
2369
+ el.appendChild(group);
2370
+ }
2371
+ return el;
2372
+ }
2373
+
2037
2374
  _createTable() {
2038
2375
  const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
2039
2376
  const { sortColumn, sortAscending } = this._state;
@@ -2046,7 +2383,10 @@ export class DataTable {
2046
2383
  // NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
2047
2384
  // way, so a table built with `pagination: false` silently showed its
2048
2385
  // first hundred rows and offered no control to reach the rest.
2049
- const pageRows = isServerSide || !this.config.pagination
2386
+ // And `paging` rows ARE the page (or everything scrolled to so far):
2387
+ // the consumer fetched them, and slicing them again would draw a
2388
+ // hundred of a thousand.
2389
+ const pageRows = isServerSide || !this.config.pagination || this._pagingConfig()
2050
2390
  ? processedRows : processedRows.slice(offset, offset + pageSize);
2051
2391
 
2052
2392
  const table = document.createElement('table');
@@ -2144,6 +2484,10 @@ export class DataTable {
2144
2484
  // stands in the order SHOWN (the page's first row on a later page).
2145
2485
  this._positionBase = isServerSide ? (configOffset || 0)
2146
2486
  : (this.config.pagination ? offset : 0);
2487
+ // `paging`: a page's rows stand at its offset in the SOURCE, which only
2488
+ // the numbering reads — their indexes stay those of `rows`.
2489
+ const paged = this._pagingConfig();
2490
+ if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
2147
2491
 
2148
2492
  // Body
2149
2493
  const tbody = document.createElement('tbody');
@@ -2628,6 +2972,12 @@ export class DataTable {
2628
2972
  }
2629
2973
 
2630
2974
  const { cellClass, nullDisplay } = this.config;
2975
+ // 0.6.1 `diff`.
2976
+ const diff = this._diffOf(row, globalIdx);
2977
+ const gap = diff?.status === 'gap';
2978
+ const changedCells = diff?.cells
2979
+ ? (diff.cells instanceof Set ? diff.cells : new Set(diff.cells))
2980
+ : null;
2631
2981
  for (let colIdx = 0; colIdx < row.length; colIdx++) {
2632
2982
  const td = document.createElement('td');
2633
2983
  td.className = this._columnTypes[colIdx] || 'text';
@@ -2635,6 +2985,13 @@ export class DataTable {
2635
2985
  // Use custom cell renderer if provided
2636
2986
  const value = row[colIdx];
2637
2987
  let handled = false;
2988
+ if (gap) {
2989
+ // The other side's row: a place, not a value.
2990
+ td.classList.add('twm-dt-diff__gap');
2991
+ tr.appendChild(td);
2992
+ continue;
2993
+ }
2994
+ if (changedCells?.has(colIdx)) td.classList.add('twm-dt-diff__cell--changed');
2638
2995
  if (this.config.renderCell) {
2639
2996
  handled = !!this.config.renderCell(td, value, colIdx, globalIdx, row);
2640
2997
  if (!handled) {
@@ -2961,6 +3318,15 @@ export class DataTable {
2961
3318
  return null;
2962
3319
  }
2963
3320
 
3321
+ /** 0.6.1. The `diff` answer for one row, or `null` when there is none or
3322
+ * its status is not one this component draws. */
3323
+ _diffOf(row, rowIdx) {
3324
+ if (typeof this.config.diff !== 'function') return null;
3325
+ const answer = this.config.diff(row, rowIdx);
3326
+ if (!answer || !DIFF_STATUSES.has(answer.status)) return null;
3327
+ return answer;
3328
+ }
3329
+
2964
3330
  /** Everything `rowClass`, `rowAttrs` and the open mark put on a `<tr>`,
2965
3331
  * taken off again first — so `updateRow` cannot leave last state's class
2966
3332
  * behind. */
@@ -2977,6 +3343,12 @@ export class DataTable {
2977
3343
  // Never take away a class this component owns.
2978
3344
  tr.__twmClasses = added.filter((c) => c !== 'data-preview-row' && c !== 'selected');
2979
3345
  }
3346
+ // 0.6.1 `diff`: the row's status, taken off again first like `rowClass`.
3347
+ for (const c of [...tr.classList]) {
3348
+ if (c === 'twm-dt-diff' || c.startsWith('twm-dt-diff--')) tr.classList.remove(c);
3349
+ }
3350
+ const diff = this._diffOf(row, rowIdx);
3351
+ if (diff) tr.classList.add('twm-dt-diff', `twm-dt-diff--${diff.status}`);
2980
3352
  if (tr.__twmAttrs) {
2981
3353
  for (const name of tr.__twmAttrs) tr.removeAttribute(name);
2982
3354
  tr.__twmAttrs = null;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * scroll_lock.js — 0.6.1. Several scroll boxes that move as one.
3
+ *
4
+ * A side-by-side comparison (two DataTables in two tiles, "now" and "then") is
5
+ * only readable if row 40 on the left stays beside row 40 on the right. A lock
6
+ * is a GROUP: every box attached to it copies the scroll position of whichever
7
+ * member the person scrolled, on the axes the lock names.
8
+ *
9
+ * const lock = createScrollLock({ axis: 'y' });
10
+ * new DataTable(left, { …, scrollLock: lock });
11
+ * new DataTable(right, { …, scrollLock: lock });
12
+ * lock.setEnabled(false); // the two scroll freely; true locks them again
13
+ *
14
+ * Any element can join with `lock.attach(el)`, which returns its detach. A
15
+ * DataTable given `scrollLock` attaches its own body box on every render and
16
+ * detaches it on the next render and on dispose, so a re-render (a sort, new
17
+ * data) never strands a dead box in the group.
18
+ *
19
+ * THE ECHO. Setting `scrollTop` on a member fires that member's own `scroll`
20
+ * event a frame later, and a naive group would then copy it back to the first
21
+ * box, which by then may have moved on — the two fight and the wheel stutters.
22
+ * So a position this lock WROTE is remembered per box, and the scroll event that
23
+ * reports exactly that position is the echo and is ignored.
24
+ *
25
+ * A box that joins is moved to the group's last position, so a pane opened
26
+ * beside a scrolled one starts level with it. Positions are copied as pixels:
27
+ * the lock aligns rows only when the members draw rows of the same height,
28
+ * which is the comparison's own contract (both sides drawn from one aligned
29
+ * row list, a gap row where a side has none).
30
+ */
31
+
32
+ /**
33
+ * @param {object} [options]
34
+ * @param {'x'|'y'|'both'} [options.axis='y'] which positions are copied
35
+ * @param {boolean} [options.enabled=true]
36
+ */
37
+ export function createScrollLock({ axis = 'y', enabled = true } = {}) {
38
+ const doY = axis === 'y' || axis === 'both';
39
+ const doX = axis === 'x' || axis === 'both';
40
+ const members = new Map(); // element -> { wrote: {top, left} | null, off }
41
+ let last = { top: 0, left: 0 };
42
+ let on = !!enabled;
43
+
44
+ function copyFrom(source) {
45
+ last = { top: source.scrollTop, left: source.scrollLeft };
46
+ if (!on) return;
47
+ for (const [el, state] of members) {
48
+ if (el === source) continue;
49
+ const top = doY ? last.top : el.scrollTop;
50
+ const left = doX ? last.left : el.scrollLeft;
51
+ if (el.scrollTop === top && el.scrollLeft === left) continue;
52
+ state.wrote = { top, left };
53
+ if (doY) el.scrollTop = top;
54
+ if (doX) el.scrollLeft = left;
55
+ }
56
+ }
57
+
58
+ function onScroll(event) {
59
+ const el = event.currentTarget;
60
+ const state = members.get(el);
61
+ if (!state) return;
62
+ const wrote = state.wrote;
63
+ state.wrote = null;
64
+ // THE ECHO of a position this lock set: not a person scrolling.
65
+ if (wrote && Math.abs(el.scrollTop - wrote.top) < 1
66
+ && Math.abs(el.scrollLeft - wrote.left) < 1) return;
67
+ copyFrom(el);
68
+ }
69
+
70
+ const lock = {
71
+ /** Join the group. Returns the function that leaves it. */
72
+ attach(el) {
73
+ if (!el || typeof el.addEventListener !== 'function') return () => {};
74
+ if (members.has(el)) return () => lock.detach(el);
75
+ const state = { wrote: null };
76
+ members.set(el, state);
77
+ el.addEventListener('scroll', onScroll, { passive: true });
78
+ if (on && (last.top || last.left)) {
79
+ state.wrote = { top: doY ? last.top : el.scrollTop,
80
+ left: doX ? last.left : el.scrollLeft };
81
+ if (doY) el.scrollTop = last.top;
82
+ if (doX) el.scrollLeft = last.left;
83
+ }
84
+ return () => lock.detach(el);
85
+ },
86
+ detach(el) {
87
+ if (!members.has(el)) return;
88
+ members.delete(el);
89
+ el.removeEventListener('scroll', onScroll);
90
+ },
91
+ /** Lock (true) or let the members scroll freely (false). Locking again
92
+ * brings every member level with the one scrolled last. */
93
+ setEnabled(value) {
94
+ const was = on;
95
+ on = !!value;
96
+ if (on && !was) {
97
+ const source = [...members.keys()].find(
98
+ (el) => el.scrollTop === last.top && el.scrollLeft === last.left)
99
+ || members.keys().next().value;
100
+ if (source) copyFrom(source);
101
+ }
102
+ },
103
+ get enabled() { return on; },
104
+ /** How many boxes are in the group (for a consumer's own checks). */
105
+ get size() { return members.size; },
106
+ dispose() {
107
+ for (const el of [...members.keys()]) lock.detach(el);
108
+ },
109
+ };
110
+ return lock;
111
+ }