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/README.md +77 -0
- package/css/base.css +76 -0
- package/css/flexdesk.css +76 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-KH2GJTKR.js → chunk-CPYBQN3Q.js} +301 -3
- package/dist/chunk-CPYBQN3Q.js.map +7 -0
- package/dist/{chunk-CNXP3SIN.js → chunk-XYUZF5CD.js} +2 -2
- package/dist/flexdesk.css +76 -0
- package/dist/flow.js +1 -1
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +88 -2
- package/dist/widgets.js.map +3 -3
- package/package.json +1 -1
- package/src/ui/components/data_table.js +375 -3
- package/src/ui/utils/scroll_lock.js +111 -0
- package/dist/chunk-KH2GJTKR.js.map +0 -7
- /package/dist/{chunk-CNXP3SIN.js.map → chunk-XYUZF5CD.js.map} +0 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|