flexdesk 0.6.1 → 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.1",
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": {
@@ -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,6 +1071,18 @@ 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
@@ -2046,6 +2139,238 @@ export class DataTable {
2046
2139
  return btn;
2047
2140
  }
2048
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
+
2049
2374
  _createTable() {
2050
2375
  const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon, onPageChange, offset: configOffset } = this.config;
2051
2376
  const { sortColumn, sortAscending } = this._state;
@@ -2058,7 +2383,10 @@ export class DataTable {
2058
2383
  // NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
2059
2384
  // way, so a table built with `pagination: false` silently showed its
2060
2385
  // first hundred rows and offered no control to reach the rest.
2061
- 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()
2062
2390
  ? processedRows : processedRows.slice(offset, offset + pageSize);
2063
2391
 
2064
2392
  const table = document.createElement('table');
@@ -2156,6 +2484,10 @@ export class DataTable {
2156
2484
  // stands in the order SHOWN (the page's first row on a later page).
2157
2485
  this._positionBase = isServerSide ? (configOffset || 0)
2158
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;
2159
2491
 
2160
2492
  // Body
2161
2493
  const tbody = document.createElement('tbody');