flexdesk 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -0
- package/css/base.css +333 -0
- package/css/flexdesk.css +333 -0
- package/dist/charts.js +2 -2
- package/dist/{chunk-WLASPRGP.js → chunk-JLCJWCRS.js} +1577 -56
- package/dist/chunk-JLCJWCRS.js.map +7 -0
- package/dist/{chunk-WAVLVXYV.js → chunk-WKTYKXWF.js} +2 -2
- package/dist/flexdesk.css +333 -0
- package/dist/flow.js +24 -8
- package/dist/flow.js.map +2 -2
- package/dist/tiles.js +2 -2
- package/dist/widgets.js +672 -1
- package/dist/widgets.js.map +4 -4
- package/package.json +1 -1
- package/src/flow/outline/editor.js +46 -8
- package/src/ui/components/data_table.js +1650 -66
- package/src/ui/components/fact_table.js +342 -0
- package/src/ui/components/record_list.js +594 -0
- package/src/ui/components/table_filter_chips.js +153 -0
- package/dist/chunk-WLASPRGP.js.map +0 -7
- /package/dist/{chunk-WAVLVXYV.js.map → chunk-WKTYKXWF.js.map} +0 -0
|
@@ -24,6 +24,11 @@ import {
|
|
|
24
24
|
matchDurationFilter, parseDateValue, parseDuration,
|
|
25
25
|
} from './table_values.js';
|
|
26
26
|
import { isRowActivation, isRowControl } from './row_activation.js';
|
|
27
|
+
import { createChipsFilter } from './table_filter_chips.js';
|
|
28
|
+
|
|
29
|
+
// 0.7.0: the built-in chips filter control, for a consumer that wants to build
|
|
30
|
+
// one outside a table (the same control `filterKinds: {kind: 'chips'}` draws).
|
|
31
|
+
export { createChipsFilter } from './table_filter_chips.js';
|
|
27
32
|
|
|
28
33
|
// 0.5.0: the value helpers a typed column uses, and the one row-activation rule,
|
|
29
34
|
// re-exported so a consumer reaches them where it reaches DataTable.
|
|
@@ -134,23 +139,41 @@ function autoDisposeSweep(entry) {
|
|
|
134
139
|
if (!table) { entry.tables.delete(ref); continue; }
|
|
135
140
|
const el = table._wrapperEl;
|
|
136
141
|
if (!el) continue;
|
|
137
|
-
if (el.isConnected) {
|
|
142
|
+
if (el.isConnected) {
|
|
143
|
+
table._autoSeen = true;
|
|
144
|
+
// 0.7.0. BACK IN: whatever grace was running for the last time it
|
|
145
|
+
// went out is over. Without this, a table taken out, put back and
|
|
146
|
+
// taken out again was judged by the FIRST departure's clock, and
|
|
147
|
+
// disposed while it had been out for less than its grace.
|
|
148
|
+
if (table._autoPending) {
|
|
149
|
+
clearTimeout(table._autoTimer);
|
|
150
|
+
table._autoTimer = null;
|
|
151
|
+
table._autoPending = false;
|
|
152
|
+
}
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
138
155
|
if (!table._autoSeen || table._autoPending) continue;
|
|
156
|
+
// 0.7.0 `keepWhileDetached: true`: out is not gone. Nothing is
|
|
157
|
+
// scheduled; the table waits to be put back (or for `dispose()`).
|
|
158
|
+
const grace = table._detachGrace();
|
|
159
|
+
if (grace === Infinity) continue;
|
|
139
160
|
// NOT AT ONCE. A renderer that takes a subtree out and puts it back in
|
|
140
161
|
// the same task (FlexDesk's own tile renderer re-appends every leaf in
|
|
141
162
|
// tree order) has put it back by the time this microtask runs — and one
|
|
142
|
-
// that takes a turn longer still gets one task's grace
|
|
143
|
-
// that is STILL out after that
|
|
163
|
+
// that takes a turn longer still gets one task's grace (or, 0.7.0, the
|
|
164
|
+
// `keepWhileDetached` grace). Only a table that is STILL out after that
|
|
165
|
+
// has been removed from the page.
|
|
144
166
|
table._autoPending = true;
|
|
145
|
-
setTimeout(() => {
|
|
167
|
+
table._autoTimer = setTimeout(() => {
|
|
146
168
|
table._autoPending = false;
|
|
169
|
+
table._autoTimer = null;
|
|
147
170
|
if (!entry.tables.has(ref)) return;
|
|
148
171
|
const now = table._wrapperEl;
|
|
149
172
|
// `_disposeOwn`, NOT `dispose()`: by now the host may hold
|
|
150
173
|
// something else — the next list mounted into it, or the pane's
|
|
151
174
|
// own message — and `dispose()` empties the host.
|
|
152
175
|
if (now && !now.isConnected) table._disposeOwn();
|
|
153
|
-
},
|
|
176
|
+
}, grace);
|
|
154
177
|
}
|
|
155
178
|
if (entry.tables.size === 0) autoDisposeRelease(entry);
|
|
156
179
|
}
|
|
@@ -197,6 +220,49 @@ function perColumn(option, colIdx) {
|
|
|
197
220
|
return option;
|
|
198
221
|
}
|
|
199
222
|
|
|
223
|
+
/** 0.7.0. Ids for the detail regions a row's toggle controls. */
|
|
224
|
+
const DETAIL_SEQ = { n: 0 };
|
|
225
|
+
|
|
226
|
+
/** 0.7.0. How a filter control's value matches a cell when its control says
|
|
227
|
+
* nothing (`match`): a list is "one of these", a `{from, to}` is a range
|
|
228
|
+
* (inclusive; dates and numbers compared as such), text is "contains", and
|
|
229
|
+
* anything else is equality as text. */
|
|
230
|
+
function defaultControlMatch(cell, value) {
|
|
231
|
+
if (Array.isArray(value)) return value.some((v) => String(v ?? '') === String(cell ?? ''));
|
|
232
|
+
if (value && typeof value === 'object' && !(value instanceof Date)
|
|
233
|
+
&& ('from' in value || 'to' in value)) {
|
|
234
|
+
const asNumber = (x) => {
|
|
235
|
+
if (x == null || x === '') return null;
|
|
236
|
+
if (x instanceof Date) return x.getTime();
|
|
237
|
+
if (typeof x === 'number') return x;
|
|
238
|
+
const t = parseDateValue(x);
|
|
239
|
+
if (Number.isFinite(t)) return t;
|
|
240
|
+
const n = parseFloat(x);
|
|
241
|
+
return Number.isFinite(n) ? n : null;
|
|
242
|
+
};
|
|
243
|
+
const c = asNumber(cell);
|
|
244
|
+
if (c === null) return false;
|
|
245
|
+
const lo = asNumber(value.from);
|
|
246
|
+
const hi = asNumber(value.to);
|
|
247
|
+
if (lo !== null && c < lo) return false;
|
|
248
|
+
if (hi !== null && c > hi) return false;
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (typeof value === 'string') {
|
|
252
|
+
return String(cell ?? '').toLowerCase().includes(value.toLowerCase());
|
|
253
|
+
}
|
|
254
|
+
return String(value) === String(cell ?? '');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 0.7.0. The chips' rule: the cell IS the chosen value (one of them, for
|
|
258
|
+
* `multiple`). */
|
|
259
|
+
function chipsMatch(spec) {
|
|
260
|
+
return (cell, value) => {
|
|
261
|
+
const want = Array.isArray(value) ? value : [value];
|
|
262
|
+
return want.some((v) => String(v ?? '') === String(cell ?? ''));
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
200
266
|
/** One sort key, comparable with `compareSortKeys`. */
|
|
201
267
|
function normaliseSortKey(k) {
|
|
202
268
|
if (k == null) return null;
|
|
@@ -302,6 +368,26 @@ const AUTOSIZE_MAX_PX = 520;
|
|
|
302
368
|
* `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
369
|
* `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
370
|
* `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.
|
|
371
|
+
* 0.7.0: WITH `dataSource` the table fetches, and is its own `paging` consumer — `offset`, `total`, `atLeast`, `hasMore` and `held` are read from the source's answers, `onPage`/`onLoadMore`/`onLoadAll` are the table's own questions (`reason` 'page', 'more', 'all'; more rows are drawn under those held), and `onModeChange` is told after the table has switched and asked again (`reason` 'mode'). The 0.7.0 pager is not drawn; `pagerLabels` and `maxJumpOffset` apply to the strip.
|
|
372
|
+
*
|
|
373
|
+
* ── 0.7.0, every one of them off unless asked for ─────────────────────────
|
|
374
|
+
* @property {Function} [rowDetail] - `(row, rowIdx, {host, key, table}) => Node|string|null|void`. A row's DETAIL, folded open under it: each row (or each row `hasRowDetail` admits) gets a toggle at the start of its first cell, and opening it draws a full-width row under the row — the function fills `host` or returns what to put in it. It is drawn when opened and dropped when closed. Keyboard: the toggle is a button (Enter/Space), and on the focused table ArrowRight opens and ArrowLeft closes the current row's detail. Which rows are open is kept by KEY (`getRowKey`, else the row's index), so it survives a sort, a filter, a page and a `setData`; under `virtualize` an open detail is part of its row's height.
|
|
375
|
+
* @property {Function} [hasRowDetail] - `(row, rowIdx) => boolean`. Whether a row has a detail at all; a row without one gets no toggle. Absent: every row has one.
|
|
376
|
+
* @property {Array} [expandedRows] - The keys of the rows open at first.
|
|
377
|
+
* @property {Function} [onRowDetailToggle] - `(rowIdx, row, expanded, key) => void`, after a detail opened or closed by a gesture or by `setRowExpanded`.
|
|
378
|
+
* @property {string} [rowDetailLabel='Details'] - The toggle's accessible name (its state is `aria-expanded`).
|
|
379
|
+
* @property {Function} [dataSource] - `(query, {signal}) => Promise<{rows, total?, hasMore?, filterCounts?} | rows>`. ROWS FROM A SERVER. The table asks for a page whenever its sort, its column filters or its page change (and on `reload()`/`refresh()`), and draws the answer AS GIVEN — no client sort, filter or slice. `query` is `{offset, limit, sort: {column, header, ascending}|null, filters: [{column, header, value}], reason}`. With `paging` the rows may be shown as one scrolling list as well as in pages — see `paging`. Only the NEWEST request paints: an older answer arriving later is dropped, and its `signal` is aborted. A failure keeps the rows on screen under `setError`'s banner. `total` is the count under these filters — a number, or null when it is not known (see `hasMore`). `rowIdx` everywhere is the row's index in the page drawn.
|
|
380
|
+
* @property {Function} [onLoad] - `(result, query) => void`, after the newest answer has been drawn.
|
|
381
|
+
* @property {Function} [onLoadError] - `(error, query) => void`, when the newest request failed (an aborted one is not a failure).
|
|
382
|
+
* @property {boolean} [hasMore] - Server-side paging (`onPageChange`) with NO total: with `totalCount` null and this a boolean, the pager offers Next exactly while it is true and has no last page. Under `dataSource` the answer's `hasMore` (or, absent, a full page) says it.
|
|
383
|
+
* @property {Object} [pagerLabels] - The pager's words: `{first, prev, next, last, noLast, tooDeep}`. The four buttons are drawn with their word beside the icon, never narrower than it (`{first: 'Newest', prev: 'Newer', next: 'Older', last: 'Oldest'}` for a log read newest first); `noLast` is the refusal on Last when the total is unknown, `tooDeep` (`(max) => string`) the one past `maxJumpOffset`.
|
|
384
|
+
* @property {'top'|'bottom'} [pagerPosition='top'] - Where the pager strip sits.
|
|
385
|
+
* @property {number} [maxJumpOffset] - The deepest offset Last and Go-to-row will jump to. Past it the control is REFUSED, disabled with the reason on it (`data-refusal`), rather than slow.
|
|
386
|
+
* @property {boolean|{interval?: number}} [follow=false] - Under `dataSource`: re-read the first page every `interval` ms (default 10000) while the table is on it and in the page, keeping the scroll position, the selection and the active row; rows not there before are drawn `twm-dt-row--new`. A tick waits while text in the table is selected or the focus is inside an open detail. Paging away turns following OFF (`onFollowChange(false)`); coming back does not turn it on again. See `setFollow`, `refresh`.
|
|
387
|
+
* @property {Function} [onFollowChange] - `(following) => void`.
|
|
388
|
+
* @property {boolean|number} [keepWhileDetached=false] - With `autoDispose`: how long a table taken out of the page may stay out and still come back drawn. A number is a grace in ms; `true` keeps it for as long as it is out (it is disposed only by `dispose()`, and what it holds meanwhile is its own DOM — nothing on the document, and a follow timer that holds it weakly). A table put back within its grace is not disposed, however many tasks it was out.
|
|
389
|
+
* @property {Object|Array|Function} [filterKinds] - FILTER CONTROLS THAT ARE NOT TEXT BOXES, per column (an object or array keyed by column index, or `(colIdx, header) => spec`). A spec is `'text'` (the 0.4 box), `{kind: 'chips', options: [{value, label, icon?, title?}], multiple?}` (one-of-N chips with counts — an option whose value is `''` or null means "no filter"), or `{create(ctx)}` / a function `create(ctx)` for the consumer's own control (a date range, a person): `ctx` is `{column, header, value, counts, setValue(v), table}`, and it returns `{el, setValue?(v), setCounts?(c), destroy?(), match?(cellValue, filterValue, row)}`. A filter's value may then be anything (an array, an object): `query.filters` forwards it as it is, and on the client the control's `match` decides (chips: equality, or membership for `multiple`). The control is built once and kept across renders, and is in the column-width measure: a column is never narrower than its control at its narrowest (the widest chip), and the cell wraps the rest.
|
|
390
|
+
* @property {Function} [onFilterChange] - `(filters) => void` with `[{column, header, value}]`, whenever a column filter changes.
|
|
305
391
|
*/
|
|
306
392
|
|
|
307
393
|
/** C10. When the scroll box has no layout — mounted hidden, or a DOM with no
|
|
@@ -402,9 +488,40 @@ export class DataTable {
|
|
|
402
488
|
dateTimeFormat: null,
|
|
403
489
|
durationFormat: null,
|
|
404
490
|
dateTimeZone: 'local',
|
|
491
|
+
// 0.7.0. Off — or exactly the 0.6 behaviour — until set. The
|
|
492
|
+
// typedef says what each does.
|
|
493
|
+
rowDetail: null,
|
|
494
|
+
hasRowDetail: null,
|
|
495
|
+
expandedRows: null,
|
|
496
|
+
onRowDetailToggle: null,
|
|
497
|
+
rowDetailLabel: 'Details',
|
|
498
|
+
dataSource: null,
|
|
499
|
+
onLoad: null,
|
|
500
|
+
onLoadError: null,
|
|
501
|
+
hasMore: undefined,
|
|
502
|
+
pagerLabels: null,
|
|
503
|
+
pagerPosition: 'top',
|
|
504
|
+
maxJumpOffset: null,
|
|
505
|
+
follow: false,
|
|
506
|
+
onFollowChange: null,
|
|
507
|
+
keepWhileDetached: false,
|
|
508
|
+
filterKinds: null,
|
|
509
|
+
onFilterChange: null,
|
|
405
510
|
...config,
|
|
406
511
|
};
|
|
407
512
|
|
|
513
|
+
// 0.7.0. Open details (by key), the server's half, follow, and the
|
|
514
|
+
// filter controls that are not text boxes.
|
|
515
|
+
this._expanded = new Set((this.config.expandedRows || []).map((k) => String(k)));
|
|
516
|
+
this._detailHeights = new Map();
|
|
517
|
+
this._source = null;
|
|
518
|
+
this._following = !!this.config.follow && !!this.config.dataSource;
|
|
519
|
+
this._followTimer = null;
|
|
520
|
+
this._newKeys = null;
|
|
521
|
+
this._filterControls = new Map();
|
|
522
|
+
this._filterCounts = new Map();
|
|
523
|
+
this._pendingFilter = null;
|
|
524
|
+
|
|
408
525
|
// 0.5.0. The open row's key, and the failure on show (`setError`).
|
|
409
526
|
this._activeKey = this.config.activeRow == null ? null : String(this.config.activeRow);
|
|
410
527
|
this._error = null;
|
|
@@ -579,6 +696,20 @@ export class DataTable {
|
|
|
579
696
|
this._state.filters.clear();
|
|
580
697
|
}
|
|
581
698
|
}
|
|
699
|
+
// 0.7.0. A filter control belongs to a column; new columns, new
|
|
700
|
+
// controls (and no counts carried onto a column they did not count).
|
|
701
|
+
if (updates.headers && (this._filterControls.size || this._filterCounts.size)) {
|
|
702
|
+
const oldHeaders = this.config.headers;
|
|
703
|
+
const newHeaders = updates.headers;
|
|
704
|
+
if (oldHeaders.length !== newHeaders.length
|
|
705
|
+
|| oldHeaders.some((h, i) => h !== newHeaders[i])) {
|
|
706
|
+
this._destroyFilterControls();
|
|
707
|
+
this._filterCounts.clear();
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (Object.prototype.hasOwnProperty.call(updates, 'filterKinds')) {
|
|
711
|
+
this._destroyFilterControls();
|
|
712
|
+
}
|
|
582
713
|
|
|
583
714
|
Object.assign(this.config, updates);
|
|
584
715
|
|
|
@@ -630,7 +761,8 @@ export class DataTable {
|
|
|
630
761
|
// table with an `emptyState`, the one thing that reads it: a table that
|
|
631
762
|
// asks for nothing new keeps 0.4's wrapper to the attribute.
|
|
632
763
|
this._wrapperEl.classList.toggle('twm-data-table-component--loading',
|
|
633
|
-
!!on && this.config.emptyState !== undefined
|
|
764
|
+
!!on && (this.config.emptyState !== undefined
|
|
765
|
+
|| !!this.config.dataSource));
|
|
634
766
|
let overlay = this._wrapperEl.querySelector('.twm-data-table__loading');
|
|
635
767
|
if (on) {
|
|
636
768
|
if (!overlay) {
|
|
@@ -763,6 +895,17 @@ export class DataTable {
|
|
|
763
895
|
* @param {number} page - Page number (0-indexed)
|
|
764
896
|
*/
|
|
765
897
|
goToPage(page) {
|
|
898
|
+
// 0.7.0 `dataSource`: the page is the SOURCE's — asked for, and drawn
|
|
899
|
+
// when it answers (the promise). Past a known total, the last page.
|
|
900
|
+
// (The release review: this set an offset nothing under a data source
|
|
901
|
+
// reads, and drew the same page again.)
|
|
902
|
+
if (this.config.dataSource) {
|
|
903
|
+
const size = this._sourcePageSize();
|
|
904
|
+
let p = Math.max(0, Math.floor(Number(page)) || 0);
|
|
905
|
+
const total = this._source?.total;
|
|
906
|
+
if (typeof total === 'number') p = Math.min(p, Math.max(0, Math.ceil(total / size) - 1));
|
|
907
|
+
return this._sourceGoTo(p * size);
|
|
908
|
+
}
|
|
766
909
|
const rowCount = this._getProcessedRows().length;
|
|
767
910
|
const maxPage = Math.ceil(rowCount / this.config.pageSize) - 1;
|
|
768
911
|
this._state.offset = Math.max(0, Math.min(page, maxPage)) * this.config.pageSize;
|
|
@@ -774,6 +917,11 @@ export class DataTable {
|
|
|
774
917
|
* @param {number} rowIndex - Row index
|
|
775
918
|
*/
|
|
776
919
|
goToRow(rowIndex) {
|
|
920
|
+
// 0.7.0 `dataSource`: the page that holds the row, from the source.
|
|
921
|
+
if (this.config.dataSource) {
|
|
922
|
+
const size = this._sourcePageSize();
|
|
923
|
+
return this.goToPage(Math.floor(Math.max(0, Math.floor(Number(rowIndex)) || 0) / size));
|
|
924
|
+
}
|
|
777
925
|
const rowCount = this._getProcessedRows().length;
|
|
778
926
|
const idx = Math.max(0, Math.min(rowIndex, rowCount - 1));
|
|
779
927
|
this._state.offset = Math.floor(idx / this.config.pageSize) * this.config.pageSize;
|
|
@@ -811,6 +959,9 @@ export class DataTable {
|
|
|
811
959
|
this._awaitWithSpinner(ret);
|
|
812
960
|
this._savePersisted();
|
|
813
961
|
this.render();
|
|
962
|
+
// 0.7.0 `dataSource`: a new order is a new question, from its first
|
|
963
|
+
// page. Drawn first (the arrow moves at once), asked after.
|
|
964
|
+
if (this.config.dataSource) this._sourceLoad({ offset: 0, reason: 'sort' });
|
|
814
965
|
}
|
|
815
966
|
|
|
816
967
|
/** Internal: if `maybePromise` is a Promise (or thenable),
|
|
@@ -829,7 +980,18 @@ export class DataTable {
|
|
|
829
980
|
this._processedRows = null;
|
|
830
981
|
this._processedIndexMap = null;
|
|
831
982
|
this._state.offset = 0;
|
|
983
|
+
this._pendingFilter = null;
|
|
832
984
|
this._savePersisted();
|
|
985
|
+
// 0.7.0. Each control shows "no filter", and whoever listens hears it.
|
|
986
|
+
for (const { control } of this._filterControls.values()) {
|
|
987
|
+
try { control.setValue?.(null); } catch (err) { console.error('[DataTable] filter control setValue threw', err); }
|
|
988
|
+
}
|
|
989
|
+
this._notifyFilterChange();
|
|
990
|
+
if (this.config.dataSource) {
|
|
991
|
+
if (this._wrapperEl) this.render();
|
|
992
|
+
this._sourceLoad({ offset: 0, reason: 'filter' });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
833
995
|
this.render();
|
|
834
996
|
}
|
|
835
997
|
|
|
@@ -854,6 +1016,12 @@ export class DataTable {
|
|
|
854
1016
|
// navigation silently died the first time the store moved.
|
|
855
1017
|
const doc = this.container.ownerDocument;
|
|
856
1018
|
const hadTableFocus = !!this._tableEl && doc?.activeElement === this._tableEl;
|
|
1019
|
+
// 0.7.0. Focus inside a filter control (it is kept and re-appended,
|
|
1020
|
+
// which a move would blur) or on a row's detail toggle (rebuilt): put
|
|
1021
|
+
// back after the render. And a filter still being typed under
|
|
1022
|
+
// `dataSource` — an answer arriving mid-word re-renders the row.
|
|
1023
|
+
const focus070 = this._captureFocus070(doc);
|
|
1024
|
+
const pendingFilter = this.config.dataSource ? this._pendingFilter : null;
|
|
857
1025
|
// SCROLL POSITION SURVIVES A RE-RENDER, for the same reason: the scroll
|
|
858
1026
|
// box is rebuilt too, and a new one starts at the top. Folding a tree
|
|
859
1027
|
// row, or any refresh, threw a reader scrolled halfway down back to row
|
|
@@ -914,9 +1082,14 @@ export class DataTable {
|
|
|
914
1082
|
// Pagination (top). `_createPagination` returns null when the
|
|
915
1083
|
// strip would be uninformative (single page, no active filter). Not
|
|
916
1084
|
// with `paging`, whose own strip (under the body) replaces it.
|
|
917
|
-
|
|
1085
|
+
// 0.7.0: under `dataSource` an empty page that is not the first still
|
|
1086
|
+
// needs it — it is the way back.
|
|
1087
|
+
this._paginationEl = null;
|
|
1088
|
+
if (pagination && (rows.length > 0 || (this._source?.offset || 0) > 0) && !this._pagingConfig()) {
|
|
918
1089
|
this._paginationEl = this._createPagination();
|
|
919
|
-
if (this._paginationEl
|
|
1090
|
+
if (this._paginationEl && this.config.pagerPosition !== 'bottom') {
|
|
1091
|
+
this._wrapperEl.appendChild(this._paginationEl);
|
|
1092
|
+
}
|
|
920
1093
|
}
|
|
921
1094
|
|
|
922
1095
|
// ── Structural split: thead and tbody live in separate
|
|
@@ -939,7 +1112,12 @@ export class DataTable {
|
|
|
939
1112
|
this._wrapperEl.appendChild(this._createErrorBanner());
|
|
940
1113
|
}
|
|
941
1114
|
|
|
942
|
-
|
|
1115
|
+
// 0.7.0. Under `dataSource` the rows ARE the filtered answer, so an
|
|
1116
|
+
// empty one must keep the filter row — or a filter that matched nothing
|
|
1117
|
+
// could never be cleared. It goes through `_createTable` and gets its
|
|
1118
|
+
// empty row there.
|
|
1119
|
+
const keepFilterRow = rows.length === 0 && !!this.config.dataSource && this.config.filterable;
|
|
1120
|
+
if (rows.length === 0 && !keepFilterRow) {
|
|
943
1121
|
// Empty state — render the same chrome as the data table
|
|
944
1122
|
// (header row in its own non-scrolling wrap) followed by a
|
|
945
1123
|
// single virtual row spanning every column with the empty
|
|
@@ -979,30 +1157,7 @@ export class DataTable {
|
|
|
979
1157
|
const bodyTable = document.createElement('table');
|
|
980
1158
|
bodyTable.className = headerTable.className;
|
|
981
1159
|
const tbody = document.createElement('tbody');
|
|
982
|
-
|
|
983
|
-
emptyTr.className = 'data-preview-row data-table__empty-row';
|
|
984
|
-
const colCount = headers.length + (showRowNumbers ? 1 : 0);
|
|
985
|
-
const emptyTd = document.createElement('td');
|
|
986
|
-
emptyTd.colSpan = colCount;
|
|
987
|
-
if (this._error) {
|
|
988
|
-
// 0.5.0. Nothing to keep: the failure takes the empty row.
|
|
989
|
-
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--error';
|
|
990
|
-
emptyTd.setAttribute('role', 'alert');
|
|
991
|
-
this._appendStateContent(emptyTd, this._errorContent());
|
|
992
|
-
} else if (this.config.emptyState !== undefined) {
|
|
993
|
-
// 0.5.0. The consumer's own words (or node), styled by class
|
|
994
|
-
// rather than inline, and quiet while the table is loading.
|
|
995
|
-
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
|
|
996
|
-
const state = typeof this.config.emptyState === 'function'
|
|
997
|
-
? this.config.emptyState() : this.config.emptyState;
|
|
998
|
-
this._appendStateContent(emptyTd, state);
|
|
999
|
-
} else {
|
|
1000
|
-
emptyTd.style.cssText
|
|
1001
|
-
= 'text-align:center; font-style:italic; color:#888; padding:16px;';
|
|
1002
|
-
emptyTd.textContent = emptyMessage;
|
|
1003
|
-
}
|
|
1004
|
-
emptyTr.appendChild(emptyTd);
|
|
1005
|
-
tbody.appendChild(emptyTr);
|
|
1160
|
+
tbody.appendChild(this._buildEmptyRow(headers.length + (showRowNumbers ? 1 : 0), emptyMessage));
|
|
1006
1161
|
bodyTable.appendChild(tbody);
|
|
1007
1162
|
tableWrap.appendChild(bodyTable);
|
|
1008
1163
|
this._wrapperEl.appendChild(tableWrap);
|
|
@@ -1031,11 +1186,24 @@ export class DataTable {
|
|
|
1031
1186
|
} else {
|
|
1032
1187
|
this._headerTableEl = null;
|
|
1033
1188
|
}
|
|
1189
|
+
// 0.7.0 `keepFilterRow`: the header and its filters, then the
|
|
1190
|
+
// empty (or failed) row the other branch would have drawn.
|
|
1191
|
+
if (keepFilterRow && this._tbodyEl) {
|
|
1192
|
+
const n = this.config.headers.length + (this.config.showRowNumbers ? 1 : 0);
|
|
1193
|
+
this._tbodyEl.appendChild(this._buildEmptyRow(n, emptyMessage));
|
|
1194
|
+
}
|
|
1034
1195
|
tableWrap.appendChild(fullTable);
|
|
1035
1196
|
this._wrapperEl.appendChild(tableWrap);
|
|
1036
1197
|
}
|
|
1037
1198
|
|
|
1038
1199
|
this._tableWrapEl = tableWrap;
|
|
1200
|
+
// 0.7.0 `pagerPosition: 'bottom'`: under the rows.
|
|
1201
|
+
if (this._paginationEl && this.config.pagerPosition === 'bottom') {
|
|
1202
|
+
this._paginationEl.classList.add('twm-pagination-controls--bottom');
|
|
1203
|
+
this._paginationEl.style.borderBottom = '0';
|
|
1204
|
+
this._paginationEl.style.borderTop = '1px solid #2a2a2a';
|
|
1205
|
+
this._wrapperEl.appendChild(this._paginationEl);
|
|
1206
|
+
}
|
|
1039
1207
|
// `paging`: the strip goes UNDER the body, where the reader's eye is
|
|
1040
1208
|
// when they reach the end of what is held.
|
|
1041
1209
|
this._pagingEl = null;
|
|
@@ -1119,7 +1287,8 @@ export class DataTable {
|
|
|
1119
1287
|
|
|
1120
1288
|
// Install interactions (readonly only prevents editing, not selection/copy)
|
|
1121
1289
|
if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
|
|
1122
|
-
|| typeof this.config.onRowActivate === 'function'
|
|
1290
|
+
|| typeof this.config.onRowActivate === 'function'
|
|
1291
|
+
|| typeof this.config.rowDetail === 'function')) {
|
|
1123
1292
|
this._installInteractions();
|
|
1124
1293
|
}
|
|
1125
1294
|
|
|
@@ -1130,6 +1299,25 @@ export class DataTable {
|
|
|
1130
1299
|
else if (hadTableFocus && this._tableEl) {
|
|
1131
1300
|
try { this._tableEl.focus({ preventScroll: true }); } catch (_) { /* detached */ }
|
|
1132
1301
|
}
|
|
1302
|
+
else if (focus070) {
|
|
1303
|
+
this._restoreFocus070(focus070);
|
|
1304
|
+
}
|
|
1305
|
+
// 0.7.0. The words still being typed, and their debounce, which
|
|
1306
|
+
// `_cleanup` cleared.
|
|
1307
|
+
if (pendingFilter) this._resumePendingFilter(pendingFilter);
|
|
1308
|
+
|
|
1309
|
+
// 0.7.0 `dataSource`: the first render asks for the first page; a
|
|
1310
|
+
// render while a request is out keeps the overlay up; following
|
|
1311
|
+
// resumes on a table drawn again.
|
|
1312
|
+
if (this.config.dataSource) {
|
|
1313
|
+
if (!this._source?.started) {
|
|
1314
|
+
this._sourceState().started = true;
|
|
1315
|
+
Promise.resolve().then(() => this._sourceLoad({ offset: 0, reason: 'initial' }));
|
|
1316
|
+
} else if (this._source.inFlight && !this._source.pendingAppend) {
|
|
1317
|
+
this.setLoading(true);
|
|
1318
|
+
}
|
|
1319
|
+
if (this._following && !this._followTimer) this._armFollow();
|
|
1320
|
+
}
|
|
1133
1321
|
}
|
|
1134
1322
|
|
|
1135
1323
|
/**
|
|
@@ -1257,6 +1445,19 @@ export class DataTable {
|
|
|
1257
1445
|
_teardown(emptyHost) {
|
|
1258
1446
|
autoDisposeUnwatch(this);
|
|
1259
1447
|
this._autoSeen = false;
|
|
1448
|
+
// 0.7.0. A grace still running, the follow timer, a request still out
|
|
1449
|
+
// (its answer must not draw into a disposed table), and the filter
|
|
1450
|
+
// controls (a picker may hang a menu on the body).
|
|
1451
|
+
if (this._autoTimer) { clearTimeout(this._autoTimer); this._autoTimer = null; }
|
|
1452
|
+
this._autoPending = false;
|
|
1453
|
+
this._disarmFollow();
|
|
1454
|
+
if (this._source) {
|
|
1455
|
+
this._source.ticket++;
|
|
1456
|
+
try { this._source.controller?.abort(); } catch (_) { /* gone */ }
|
|
1457
|
+
this._source.controller = null;
|
|
1458
|
+
this._source.inFlight = false;
|
|
1459
|
+
}
|
|
1460
|
+
this._destroyFilterControls();
|
|
1260
1461
|
this._runOffs(this._activationOffs);
|
|
1261
1462
|
this._activationOffs = [];
|
|
1262
1463
|
this._cleanup();
|
|
@@ -1295,7 +1496,7 @@ export class DataTable {
|
|
|
1295
1496
|
_getProcessedRows() {
|
|
1296
1497
|
if (this._processedRows !== null) return this._processedRows;
|
|
1297
1498
|
|
|
1298
|
-
const isServerSide =
|
|
1499
|
+
const isServerSide = this._isServerSide();
|
|
1299
1500
|
let rows = this.config.rows;
|
|
1300
1501
|
let indexMap = rows.map((_, i) => i);
|
|
1301
1502
|
|
|
@@ -1322,6 +1523,9 @@ export class DataTable {
|
|
|
1322
1523
|
const filters = this._state.filters;
|
|
1323
1524
|
const filteredRows = [];
|
|
1324
1525
|
const filteredMap = [];
|
|
1526
|
+
// 0.7.0: each column's control rule, asked once rather than per row.
|
|
1527
|
+
const matchers = new Map();
|
|
1528
|
+
for (const colIdx of filters.keys()) matchers.set(colIdx, this._filterMatcher(colIdx));
|
|
1325
1529
|
|
|
1326
1530
|
for (let i = 0; i < rows.length; i++) {
|
|
1327
1531
|
const row = rows[i];
|
|
@@ -1330,6 +1534,14 @@ export class DataTable {
|
|
|
1330
1534
|
for (const [colIdx, filterText] of filters) {
|
|
1331
1535
|
if (!filterText) continue;
|
|
1332
1536
|
const value = row[colIdx];
|
|
1537
|
+
// 0.7.0 `filterKinds`: a control that is not a text box decides
|
|
1538
|
+
// by its own rule, over a value that need not be text.
|
|
1539
|
+
const kindMatch = matchers.get(colIdx);
|
|
1540
|
+
if (kindMatch) {
|
|
1541
|
+
if (this._isEmptyFilterValue(filterText)) continue;
|
|
1542
|
+
if (!kindMatch(value, filterText, row)) { pass = false; break; }
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1333
1545
|
const colType = this._valueKind(colIdx);
|
|
1334
1546
|
|
|
1335
1547
|
if (colType === 'num') {
|
|
@@ -1520,6 +1732,20 @@ export class DataTable {
|
|
|
1520
1732
|
const th = document.createElement('th');
|
|
1521
1733
|
th.className = 'data-table__filter-cell';
|
|
1522
1734
|
|
|
1735
|
+
// 0.7.0 `filterKinds`: the consumer's (or the built-in chips')
|
|
1736
|
+
// control in place of the text box — the SAME element every render.
|
|
1737
|
+
const control = this._filterControlFor(colIdx, header);
|
|
1738
|
+
if (control) {
|
|
1739
|
+
th.classList.add('data-table__filter-cell--control');
|
|
1740
|
+
// The measuring pass keeps a row holding a control (its text
|
|
1741
|
+
// boxes stay out): see `_measureNaturalWidths`.
|
|
1742
|
+
tr.classList.add('twm-data-table__filter-row--controls');
|
|
1743
|
+
th.addEventListener('click', (e) => e.stopPropagation());
|
|
1744
|
+
if (control.el) th.appendChild(control.el);
|
|
1745
|
+
tr.appendChild(th);
|
|
1746
|
+
return;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1523
1749
|
const wrapper = document.createElement('div');
|
|
1524
1750
|
wrapper.className = 'twm-data-table__filter-wrapper';
|
|
1525
1751
|
|
|
@@ -1868,17 +2094,25 @@ export class DataTable {
|
|
|
1868
2094
|
if (this._filterDebounceTimer) {
|
|
1869
2095
|
clearTimeout(this._filterDebounceTimer);
|
|
1870
2096
|
}
|
|
2097
|
+
// 0.7.0. What is waiting, so a render under `dataSource` (an answer
|
|
2098
|
+
// arriving mid-word) can put it back — see `_resumePendingFilter`.
|
|
2099
|
+
this._pendingFilter = { colIdx, value };
|
|
1871
2100
|
this._filterDebounceTimer = setTimeout(() => {
|
|
2101
|
+
this._pendingFilter = null;
|
|
1872
2102
|
this._onFilterInput(colIdx, value);
|
|
1873
2103
|
}, 200);
|
|
1874
2104
|
}
|
|
1875
2105
|
|
|
1876
2106
|
_onFilterInput(colIdx, value) {
|
|
1877
|
-
|
|
2107
|
+
// 0.7.0. A filter value need not be text (a control's array, a range),
|
|
2108
|
+
// and an empty one of those is no filter. Text is judged as in 0.4.
|
|
2109
|
+
const empty = typeof value === 'string' ? !value : this._isEmptyFilterValue(value);
|
|
2110
|
+
if (!empty) {
|
|
1878
2111
|
this._state.filters.set(colIdx, value);
|
|
1879
2112
|
} else {
|
|
1880
2113
|
this._state.filters.delete(colIdx);
|
|
1881
2114
|
}
|
|
2115
|
+
this._pendingFilter = null;
|
|
1882
2116
|
|
|
1883
2117
|
this._invalidateProcessedCache();
|
|
1884
2118
|
this._state.offset = 0;
|
|
@@ -1886,9 +2120,40 @@ export class DataTable {
|
|
|
1886
2120
|
this._state.anchorIndex = null;
|
|
1887
2121
|
|
|
1888
2122
|
this._savePersisted();
|
|
2123
|
+
// 0.7.0. The column's control shows the value, and listeners hear it.
|
|
2124
|
+
const entry = this._filterControls.get(colIdx);
|
|
2125
|
+
if (entry) {
|
|
2126
|
+
try { entry.control.setValue?.(empty ? null : value); } catch (err) {
|
|
2127
|
+
console.error('[DataTable] filter control setValue threw', err);
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
this._notifyFilterChange();
|
|
2131
|
+
// 0.7.0 `dataSource`: the server filters. Nothing to redraw until it
|
|
2132
|
+
// answers (the box shows what was typed); a control's own state
|
|
2133
|
+
// changed, so it repaints itself.
|
|
2134
|
+
if (this.config.dataSource) {
|
|
2135
|
+
this._sourceLoad({ offset: 0, reason: 'filter' });
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
1889
2138
|
this.render();
|
|
1890
2139
|
}
|
|
1891
2140
|
|
|
2141
|
+
/** 0.7.0. Put back a filter that was still being typed when a render
|
|
2142
|
+
* rebuilt its box: the words, and the debounce that will apply them. */
|
|
2143
|
+
_resumePendingFilter({ colIdx, value }) {
|
|
2144
|
+
const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
|
|
2145
|
+
const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
|
|
2146
|
+
const input = cell?.querySelector('.twm-data-table__filter-input');
|
|
2147
|
+
if (input && input.value !== value) {
|
|
2148
|
+
const focused = input.ownerDocument?.activeElement === input;
|
|
2149
|
+
input.value = value;
|
|
2150
|
+
const clear = cell.querySelector('.twm-data-table__filter-clear');
|
|
2151
|
+
if (clear) clear.style.display = value ? '' : 'none';
|
|
2152
|
+
if (focused) input.setSelectionRange(value.length, value.length);
|
|
2153
|
+
}
|
|
2154
|
+
this._onFilterInputDebounced(colIdx, value);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
1892
2157
|
_getActiveFilterColIdx() {
|
|
1893
2158
|
const active = document.activeElement;
|
|
1894
2159
|
if (!active || !active.classList.contains('twm-data-table__filter-input')) return null;
|
|
@@ -1992,6 +2257,10 @@ export class DataTable {
|
|
|
1992
2257
|
}
|
|
1993
2258
|
|
|
1994
2259
|
_createPagination() {
|
|
2260
|
+
// 0.7.0. Any of the new pager's options takes the new pager, which
|
|
2261
|
+
// draws the 0.6 strip's words for a known total and something honest
|
|
2262
|
+
// for an unknown one. Without them, the 0.6 strip below, untouched.
|
|
2263
|
+
if (this._usesPager070()) return this._createPager070();
|
|
1995
2264
|
const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
|
|
1996
2265
|
|
|
1997
2266
|
// Server-side pagination uses config offset, client-side uses state offset
|
|
@@ -2152,6 +2421,33 @@ export class DataTable {
|
|
|
2152
2421
|
if (!p || typeof p !== 'object') return null;
|
|
2153
2422
|
const size = Number.isFinite(p.pageSize) && p.pageSize >= 1
|
|
2154
2423
|
? Math.floor(p.pageSize) : this.config.pageSize;
|
|
2424
|
+
// 0.7.0 — `paging` OVER A `dataSource`. ONE STRIP, ONE SET OF RULES:
|
|
2425
|
+
// the facts the 0.6.2 consumer hands in (`offset`, `total`, `hasMore`,
|
|
2426
|
+
// the rows held) are the source's last answer here, and the four
|
|
2427
|
+
// callbacks the consumer would write are the table's own — a page turn,
|
|
2428
|
+
// the next rows, all of them, the other mode — so the strip, the soft
|
|
2429
|
+
// and hard limits, the busy state and the load-near-the-end trigger are
|
|
2430
|
+
// the same code with or without a server the table asks itself.
|
|
2431
|
+
if (this.config.dataSource) {
|
|
2432
|
+
const src = this._source || { offset: 0, total: null, atLeast: 0, hasMore: false };
|
|
2433
|
+
return {
|
|
2434
|
+
...p,
|
|
2435
|
+
pageSize: size,
|
|
2436
|
+
offset: src.offset || 0,
|
|
2437
|
+
held: this.config.rows?.length || 0,
|
|
2438
|
+
total: src.total,
|
|
2439
|
+
atLeast: src.atLeast || 0,
|
|
2440
|
+
hasMore: !!src.hasMore,
|
|
2441
|
+
loadMorePx: Number.isFinite(p.loadMorePx) ? p.loadMorePx : PAGING_LOAD_MORE_PX,
|
|
2442
|
+
onPage: (offset) => this._sourceGoTo(offset),
|
|
2443
|
+
onLoadMore: () => this._sourceLoadMore(),
|
|
2444
|
+
onLoadAll: () => this._sourceLoadAll(),
|
|
2445
|
+
// The switch is drawn, as in 0.6.2, only for a consumer that
|
|
2446
|
+
// listens; the table switches and then tells it.
|
|
2447
|
+
onModeChange: typeof p.onModeChange === 'function'
|
|
2448
|
+
? (mode) => this._sourceSetMode(mode) : undefined,
|
|
2449
|
+
};
|
|
2450
|
+
}
|
|
2155
2451
|
return {
|
|
2156
2452
|
...p,
|
|
2157
2453
|
pageSize: size,
|
|
@@ -2186,7 +2482,15 @@ export class DataTable {
|
|
|
2186
2482
|
*/
|
|
2187
2483
|
setPaging(patch = {}) {
|
|
2188
2484
|
if (!this.config.paging) return;
|
|
2485
|
+
const before = this.config.dataSource ? this.pagingState?.mode : null;
|
|
2189
2486
|
this.config.paging = { ...this.config.paging, ...patch };
|
|
2487
|
+
// 0.7.0: over a `dataSource` the rows held are the table's, so a patch
|
|
2488
|
+
// that changes which way they are shown asks again from the start —
|
|
2489
|
+
// a list scrolled to 3,000 rows is not page 1.
|
|
2490
|
+
if (before && this.pagingState?.mode !== before) {
|
|
2491
|
+
this._sourceLoad({ offset: 0, reason: 'mode' });
|
|
2492
|
+
return;
|
|
2493
|
+
}
|
|
2190
2494
|
this._refreshPagingStrip();
|
|
2191
2495
|
this._pagingMaybeLoadMore();
|
|
2192
2496
|
}
|
|
@@ -2242,6 +2546,9 @@ export class DataTable {
|
|
|
2242
2546
|
_pagingMaybeLoadMore() {
|
|
2243
2547
|
const p = this._pagingConfig();
|
|
2244
2548
|
if (!p || !p.hasMore || this._pagingBusy) return;
|
|
2549
|
+
// 0.7.0: not while the source is answering another question (a sort, a
|
|
2550
|
+
// filter, a refresh) — those rows are about to be replaced.
|
|
2551
|
+
if (this.config.dataSource && this._source?.inFlight) return;
|
|
2245
2552
|
const state = this.pagingState;
|
|
2246
2553
|
if (state.mode !== 'scroll' || p.held >= state.hardLimit) return;
|
|
2247
2554
|
const wrap = this._tableWrapEl;
|
|
@@ -2284,10 +2591,10 @@ export class DataTable {
|
|
|
2284
2591
|
else if (known > to) of = ` of more than ${n(known - 1)}`;
|
|
2285
2592
|
info.textContent = held ? `Rows ${n(from)}–${n(to)}${of}` : 'No rows on this page';
|
|
2286
2593
|
|
|
2287
|
-
|
|
2288
|
-
const
|
|
2289
|
-
const
|
|
2290
|
-
|
|
2594
|
+
// The same page arithmetic as the pager above the rows (`_pageNav`).
|
|
2595
|
+
const pn = this._pageNav({ offset: p.offset, held, total, hasMore: p.hasMore, pageSize });
|
|
2596
|
+
const { hasPrev, hasNext } = pn;
|
|
2597
|
+
const lastOffset = exact && total > 0 ? pn.lastOffset : null;
|
|
2291
2598
|
// A disabled button's click is ignored here too: a browser sends none,
|
|
2292
2599
|
// but a synthetic one (a test, a script) still reaches the listener.
|
|
2293
2600
|
const go = (offset) => (ev) => {
|
|
@@ -2296,26 +2603,36 @@ export class DataTable {
|
|
|
2296
2603
|
const nav = document.createElement('span');
|
|
2297
2604
|
nav.className = 'twm-dt-paging__nav';
|
|
2298
2605
|
const off = !!busy || typeof p.onPage !== 'function';
|
|
2606
|
+
// 0.7.0: with the new pager's options (`pagerLabels`,
|
|
2607
|
+
// `maxJumpOffset`, or a `dataSource`) the strip's buttons are the
|
|
2608
|
+
// new pager's — words beside the icons, and a refusal said on the
|
|
2609
|
+
// control. Without them, exactly the 0.6.2 buttons.
|
|
2610
|
+
const words = this._usesPager070();
|
|
2611
|
+
const button = (icon, name, tip, disabled, onClick, refusal = null) => (words
|
|
2612
|
+
? this._pagerButton(icon, name, tip, disabled, onClick, refusal)
|
|
2613
|
+
: this._createPaginationBtn(icon, tip, disabled, onClick));
|
|
2299
2614
|
nav.append(
|
|
2300
|
-
|
|
2301
|
-
|
|
2615
|
+
button('first_page', 'first', 'First page', off || !hasPrev, go(0)),
|
|
2616
|
+
button('chevron_left', 'prev', 'Previous page', off || !hasPrev,
|
|
2302
2617
|
go(Math.max(0, p.offset - pageSize))),
|
|
2303
2618
|
);
|
|
2304
2619
|
const page = document.createElement('span');
|
|
2305
2620
|
page.className = 'twm-dt-paging__page';
|
|
2306
|
-
const current = Math.floor(p.offset / pageSize) + 1;
|
|
2307
2621
|
page.textContent = exact
|
|
2308
|
-
? `Page ${n(
|
|
2309
|
-
: `Page ${n(
|
|
2622
|
+
? `Page ${n(pn.page)} of ${n(pn.pages)}`
|
|
2623
|
+
: `Page ${n(pn.page)}`;
|
|
2310
2624
|
nav.append(
|
|
2311
2625
|
page,
|
|
2312
|
-
|
|
2626
|
+
button('chevron_right', 'next', 'Next page', off || !hasNext,
|
|
2313
2627
|
go(p.offset + pageSize)),
|
|
2314
2628
|
// The last page needs the exact count: without it there is no
|
|
2315
2629
|
// offset to go to, and the button says so by being off.
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2630
|
+
words
|
|
2631
|
+
? button('last_page', 'last', 'Last page', off || !hasNext || lastOffset === null,
|
|
2632
|
+
go(lastOffset), pn.lastRefusal)
|
|
2633
|
+
: button('last_page', 'last',
|
|
2634
|
+
lastOffset === null ? 'Last page (the total is not known)' : 'Last page',
|
|
2635
|
+
off || !hasNext || lastOffset === null, go(lastOffset)),
|
|
2319
2636
|
);
|
|
2320
2637
|
el.appendChild(nav);
|
|
2321
2638
|
} else {
|
|
@@ -2377,12 +2694,17 @@ export class DataTable {
|
|
|
2377
2694
|
}
|
|
2378
2695
|
|
|
2379
2696
|
_createTable() {
|
|
2380
|
-
const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon
|
|
2697
|
+
const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon } = this.config;
|
|
2381
2698
|
const { sortColumn, sortAscending } = this._state;
|
|
2699
|
+
// 0.7.0 `dataSource`: a row's index is its place in the page drawn
|
|
2700
|
+
// (`config.rows`), so every lookup by index — selection, copy,
|
|
2701
|
+
// `updateRow` — reads the row it means on any page. The page's offset
|
|
2702
|
+
// is the server's, and only numbers the rows.
|
|
2703
|
+
const configOffset = this.config.dataSource ? 0 : this.config.offset;
|
|
2382
2704
|
|
|
2383
2705
|
// Server-side pagination: rows already represent current page, use config offset for row numbering
|
|
2384
2706
|
// Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
|
|
2385
|
-
const isServerSide =
|
|
2707
|
+
const isServerSide = this._isServerSide();
|
|
2386
2708
|
const processedRows = isServerSide ? rows : this._getProcessedRows();
|
|
2387
2709
|
const offset = isServerSide ? (configOffset || 0) : this._state.offset;
|
|
2388
2710
|
// NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
|
|
@@ -2487,11 +2809,13 @@ export class DataTable {
|
|
|
2487
2809
|
|
|
2488
2810
|
// 0.5.0 `showRowNumbers: 'position'`: where the first row drawn here
|
|
2489
2811
|
// stands in the order SHOWN (the page's first row on a later page).
|
|
2490
|
-
this._positionBase =
|
|
2812
|
+
this._positionBase = this.config.dataSource ? (this._source?.offset || 0)
|
|
2813
|
+
: isServerSide ? (configOffset || 0)
|
|
2491
2814
|
: (this.config.pagination ? offset : 0);
|
|
2492
2815
|
// `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
|
-
|
|
2816
|
+
// the numbering reads — their indexes stay those of `rows`. Over a
|
|
2817
|
+
// `dataSource` that offset IS the source's, counted above once.
|
|
2818
|
+
const paged = this.config.dataSource ? null : this._pagingConfig();
|
|
2495
2819
|
if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
|
|
2496
2820
|
|
|
2497
2821
|
// Body
|
|
@@ -2505,7 +2829,10 @@ export class DataTable {
|
|
|
2505
2829
|
});
|
|
2506
2830
|
} else {
|
|
2507
2831
|
pageRows.forEach((row, localIdx) => {
|
|
2508
|
-
|
|
2832
|
+
const tr = this._buildBodyRow(row, globalOf(localIdx), localIdx);
|
|
2833
|
+
tbody.appendChild(tr);
|
|
2834
|
+
// 0.7.0 `rowDetail`: an open detail goes in right under its row.
|
|
2835
|
+
if (this._detailOn()) this._appendDetailIfOpen(tr, null);
|
|
2509
2836
|
});
|
|
2510
2837
|
}
|
|
2511
2838
|
|
|
@@ -2669,6 +2996,9 @@ export class DataTable {
|
|
|
2669
2996
|
const offsets = new Float64Array(n + 1);
|
|
2670
2997
|
const fn = typeof this.config.virtualRowHeight === 'function'
|
|
2671
2998
|
? this.config.virtualRowHeight : null;
|
|
2999
|
+
// 0.7.0 `rowDetail`: an open detail is part of its row's height —
|
|
3000
|
+
// measured once drawn, estimated until then.
|
|
3001
|
+
const details = this._detailOn() && this._expanded.size > 0;
|
|
2672
3002
|
let y = 0;
|
|
2673
3003
|
for (let i = 0; i < n; i++) {
|
|
2674
3004
|
let h = v.base;
|
|
@@ -2676,6 +3006,7 @@ export class DataTable {
|
|
|
2676
3006
|
const asked = fn(v.globalOf(i), v.base);
|
|
2677
3007
|
h = Number.isFinite(asked) && asked > 0 ? asked : 0;
|
|
2678
3008
|
}
|
|
3009
|
+
if (details && h > 0) h += this._detailHeightOf(v.rows[i], v.globalOf(i), v.base);
|
|
2679
3010
|
heights[i] = h;
|
|
2680
3011
|
offsets[i] = y;
|
|
2681
3012
|
y += h;
|
|
@@ -2718,6 +3049,14 @@ export class DataTable {
|
|
|
2718
3049
|
* larger than half a pixel, so rows of mixed heights cannot make the
|
|
2719
3050
|
* offsets move under the reader on every scroll.
|
|
2720
3051
|
*
|
|
3052
|
+
* 0.7.0 `rowDetail`: AN OPEN DETAIL IS PART OF ITS ROW'S HEIGHT, and is
|
|
3053
|
+
* measured at the same moments. It is never one of the rows the median is
|
|
3054
|
+
* taken from (the window's map holds the rows, not their details), so a
|
|
3055
|
+
* tall detail cannot pass for a row; it is read on its own, into the
|
|
3056
|
+
* row's height — and read again here, because a detail's height moves for
|
|
3057
|
+
* the same reasons a row's does (the column widths fixed, a font), and
|
|
3058
|
+
* more: it wraps across the whole table's width.
|
|
3059
|
+
*
|
|
2721
3060
|
* @returns {boolean} whether the heights changed (the offsets are recomputed).
|
|
2722
3061
|
*/
|
|
2723
3062
|
_virtMeasure() {
|
|
@@ -2731,14 +3070,21 @@ export class DataTable {
|
|
|
2731
3070
|
if (h > 0) heights.push(h);
|
|
2732
3071
|
if (heights.length >= 64) break;
|
|
2733
3072
|
}
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
3073
|
+
const details = this._detailOn() && this._expanded.size > 0
|
|
3074
|
+
&& this._virtReadDetailHeights(v.map.values());
|
|
3075
|
+
let base = false;
|
|
3076
|
+
if (heights.length) {
|
|
3077
|
+
heights.sort((a, b) => a - b);
|
|
3078
|
+
const h = heights[heights.length >> 1];
|
|
3079
|
+
const tolerance = v.measured ? 0.5 : 0.01;
|
|
3080
|
+
v.measured = true;
|
|
3081
|
+
if (Math.abs(h - v.base) > tolerance) {
|
|
3082
|
+
v.base = h;
|
|
3083
|
+
this._virtBase = h;
|
|
3084
|
+
base = true;
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
if (!base && !details) return false;
|
|
2742
3088
|
this._virtComputeOffsets();
|
|
2743
3089
|
return true;
|
|
2744
3090
|
}
|
|
@@ -2818,7 +3164,12 @@ export class DataTable {
|
|
|
2818
3164
|
for (let i = start; i < end; i++) if (v.heights[i] > 0) want.push(i);
|
|
2819
3165
|
const wanted = new Set(want);
|
|
2820
3166
|
for (const [i, tr] of v.map) {
|
|
2821
|
-
if (!wanted.has(i)) {
|
|
3167
|
+
if (!wanted.has(i)) {
|
|
3168
|
+
tr.remove();
|
|
3169
|
+
this._removeDetailPieces(tr); // 0.7.0: its detail goes with it
|
|
3170
|
+
v.map.delete(i);
|
|
3171
|
+
removed?.push(tr);
|
|
3172
|
+
}
|
|
2822
3173
|
}
|
|
2823
3174
|
v.start = start;
|
|
2824
3175
|
v.end = end;
|
|
@@ -2835,10 +3186,14 @@ export class DataTable {
|
|
|
2835
3186
|
const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
|
|
2836
3187
|
v.map.set(i, tr);
|
|
2837
3188
|
tbody.insertBefore(tr, ref);
|
|
3189
|
+
// 0.7.0: an open detail between the row and the one after it.
|
|
3190
|
+
if (this._detailOn()) this._appendDetailIfOpen(tr, ref);
|
|
2838
3191
|
ref = tr;
|
|
2839
3192
|
added.push(tr);
|
|
2840
3193
|
}
|
|
2841
3194
|
added.reverse();
|
|
3195
|
+
// 0.7.0: the details just drawn have a real height now.
|
|
3196
|
+
if (this._detailOn() && added.length) this._virtMeasureDetails(added);
|
|
2842
3197
|
return added;
|
|
2843
3198
|
}
|
|
2844
3199
|
|
|
@@ -3054,6 +3409,9 @@ export class DataTable {
|
|
|
3054
3409
|
if (colIdx === 0 && typeof this.config.rowIcon === 'function') {
|
|
3055
3410
|
this._prependRowIcon(td, this.config.rowIcon(row, globalIdx));
|
|
3056
3411
|
}
|
|
3412
|
+
// 0.7.0 `rowDetail`: the toggle, in front of everything in the
|
|
3413
|
+
// first data cell.
|
|
3414
|
+
if (colIdx === 0 && this._detailOn()) this._prependDetailToggle(td, tr, row, globalIdx);
|
|
3057
3415
|
|
|
3058
3416
|
// Cell-level right-click hook (P2). Fires before the row-level
|
|
3059
3417
|
// hook and the built-in context menu — the caller can
|
|
@@ -3173,6 +3531,8 @@ export class DataTable {
|
|
|
3173
3531
|
// 0.5.0. The row's data changed, so its classes, attributes, key and
|
|
3174
3532
|
// open mark are asked again (a key paused, a delivery that died).
|
|
3175
3533
|
this._decorateRow(tr, row, index);
|
|
3534
|
+
// 0.7.0. An open detail is of the row's data, so it is drawn again.
|
|
3535
|
+
if (this._detailOn()) this._redrawDetail(tr);
|
|
3176
3536
|
|
|
3177
3537
|
if (focusedCol >= 0 && tr.children[focusedCol]) {
|
|
3178
3538
|
tr.children[focusedCol].focus?.();
|
|
@@ -3180,6 +3540,1168 @@ export class DataTable {
|
|
|
3180
3540
|
return true;
|
|
3181
3541
|
}
|
|
3182
3542
|
|
|
3543
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3544
|
+
// 0.7.0 — a row's detail (#26)
|
|
3545
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3546
|
+
//
|
|
3547
|
+
// A detail is TWO rows after its row: the detail itself (one cell across
|
|
3548
|
+
// every column) and a hidden filler. The filler is the stripe: rows are
|
|
3549
|
+
// striped by `tr:nth-child(even)`, a question about POSITION, and one extra
|
|
3550
|
+
// row would flip the stripe of every row below an open detail. Two keep
|
|
3551
|
+
// the parity — the trick `_virtSpacers` already plays for the window.
|
|
3552
|
+
//
|
|
3553
|
+
// Neither row has a `__rowIndex`, so everything that walks the body for
|
|
3554
|
+
// rows of data (selection, the open mark, `_firstBodyRow`, `getRowElement`,
|
|
3555
|
+
// the activation and the copy menu) steps over them as it steps over a
|
|
3556
|
+
// spacer.
|
|
3557
|
+
|
|
3558
|
+
_detailOn() {
|
|
3559
|
+
return typeof this.config.rowDetail === 'function';
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
_rowHasDetail(row, rowIdx) {
|
|
3563
|
+
const fn = this.config.hasRowDetail;
|
|
3564
|
+
if (typeof fn !== 'function') return true;
|
|
3565
|
+
try { return !!fn(row, rowIdx); } catch (err) {
|
|
3566
|
+
console.error('[DataTable] hasRowDetail threw', err);
|
|
3567
|
+
return false;
|
|
3568
|
+
}
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/** The toggle: a real `<button>` (so Enter and Space work on it, and it is
|
|
3572
|
+
* a control `onRowActivate` leaves alone), its glyph drawn by CSS so the
|
|
3573
|
+
* cell's text, its tooltip and a copy stay the cell's own. */
|
|
3574
|
+
_prependDetailToggle(td, tr, row, rowIdx) {
|
|
3575
|
+
if (!this._rowHasDetail(row, rowIdx)) {
|
|
3576
|
+
// A row with no detail keeps the toggle's room, so its first cell's
|
|
3577
|
+
// text lines up with the rows that have one.
|
|
3578
|
+
const gap = document.createElement('span');
|
|
3579
|
+
gap.className = 'twm-dt-detail-toggle-gap';
|
|
3580
|
+
gap.setAttribute('aria-hidden', 'true');
|
|
3581
|
+
td.insertBefore(gap, td.firstChild);
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
const key = this._rowKey(row, rowIdx);
|
|
3585
|
+
const open = key !== null && this._expanded.has(key);
|
|
3586
|
+
const btn = document.createElement('button');
|
|
3587
|
+
btn.type = 'button';
|
|
3588
|
+
btn.className = 'material-symbols-outlined twm-dt-detail-toggle';
|
|
3589
|
+
btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
3590
|
+
btn.setAttribute('aria-label', String(this.config.rowDetailLabel || 'Details'));
|
|
3591
|
+
btn.title = String(this.config.rowDetailLabel || 'Details');
|
|
3592
|
+
btn.addEventListener('click', (ev) => {
|
|
3593
|
+
// Its own gesture: it neither selects nor opens the row.
|
|
3594
|
+
ev.preventDefault();
|
|
3595
|
+
ev.stopPropagation();
|
|
3596
|
+
this._setRowElExpanded(tr, btn.getAttribute('aria-expanded') !== 'true', ev);
|
|
3597
|
+
});
|
|
3598
|
+
td.insertBefore(btn, td.firstChild);
|
|
3599
|
+
td.classList.add('twm-dt-cell--has-detail-toggle');
|
|
3600
|
+
tr.classList.add('twm-dt-row--has-detail');
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
/** After `tr` has been placed: its open detail, in front of `ref` (the row
|
|
3604
|
+
* after it, or null for the end of the body). */
|
|
3605
|
+
_appendDetailIfOpen(tr, ref) {
|
|
3606
|
+
const row = tr.__row;
|
|
3607
|
+
const idx = tr.__rowIndex;
|
|
3608
|
+
if (!this._rowHasDetail(row, idx)) return;
|
|
3609
|
+
const key = this._rowKey(row, idx);
|
|
3610
|
+
if (key === null || !this._expanded.has(key)) return;
|
|
3611
|
+
const pieces = this._buildDetailPieces(tr, key);
|
|
3612
|
+
const parent = tr.parentNode;
|
|
3613
|
+
if (!parent) return;
|
|
3614
|
+
for (const p of pieces) parent.insertBefore(p, ref);
|
|
3615
|
+
}
|
|
3616
|
+
|
|
3617
|
+
_buildDetailPieces(tr, key) {
|
|
3618
|
+
const n = (this.config.headers?.length || 0) + (this.config.showRowNumbers ? 1 : 0);
|
|
3619
|
+
const detailTr = document.createElement('tr');
|
|
3620
|
+
detailTr.className = 'twm-dt-detail-row';
|
|
3621
|
+
const td = document.createElement('td');
|
|
3622
|
+
td.className = 'twm-dt-detail-cell';
|
|
3623
|
+
td.colSpan = Math.max(1, n);
|
|
3624
|
+
const host = document.createElement('div');
|
|
3625
|
+
host.className = 'twm-dt-detail';
|
|
3626
|
+
DETAIL_SEQ.n += 1;
|
|
3627
|
+
host.id = `twm-dt-detail-${DETAIL_SEQ.n}`;
|
|
3628
|
+
td.appendChild(host);
|
|
3629
|
+
detailTr.appendChild(td);
|
|
3630
|
+
const filler = document.createElement('tr');
|
|
3631
|
+
filler.className = 'twm-dt-detail-filler';
|
|
3632
|
+
filler.hidden = true;
|
|
3633
|
+
filler.setAttribute('aria-hidden', 'true');
|
|
3634
|
+
detailTr.__detailOf = tr;
|
|
3635
|
+
tr.__detail = [detailTr, filler];
|
|
3636
|
+
this._drawDetail(tr, host, key);
|
|
3637
|
+
const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
|
|
3638
|
+
if (btn) btn.setAttribute('aria-controls', host.id);
|
|
3639
|
+
return tr.__detail;
|
|
3640
|
+
}
|
|
3641
|
+
|
|
3642
|
+
_drawDetail(tr, host, key) {
|
|
3643
|
+
host.replaceChildren();
|
|
3644
|
+
let out;
|
|
3645
|
+
try {
|
|
3646
|
+
out = this.config.rowDetail(tr.__row, tr.__rowIndex, { host, key, table: this });
|
|
3647
|
+
} catch (err) {
|
|
3648
|
+
console.error('[DataTable] rowDetail threw', err);
|
|
3649
|
+
out = err?.message || String(err);
|
|
3650
|
+
}
|
|
3651
|
+
if (out == null || out === false || out === host) return;
|
|
3652
|
+
if (typeof Node !== 'undefined' && out instanceof Node) host.appendChild(out);
|
|
3653
|
+
else host.textContent = String(out);
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
_removeDetailPieces(tr) {
|
|
3657
|
+
const pieces = tr.__detail;
|
|
3658
|
+
if (!pieces) return;
|
|
3659
|
+
for (const p of pieces) p.remove();
|
|
3660
|
+
tr.__detail = null;
|
|
3661
|
+
const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
|
|
3662
|
+
if (btn) btn.removeAttribute('aria-controls');
|
|
3663
|
+
}
|
|
3664
|
+
|
|
3665
|
+
/** `updateRow`'s half: the detail follows the row's new data. */
|
|
3666
|
+
_redrawDetail(tr) {
|
|
3667
|
+
const key = this._rowKey(tr.__row, tr.__rowIndex);
|
|
3668
|
+
const want = this._rowHasDetail(tr.__row, tr.__rowIndex) && key !== null && this._expanded.has(key);
|
|
3669
|
+
const pieces = tr.__detail;
|
|
3670
|
+
if (!want) { if (pieces) this._removeDetailPieces(tr); return; }
|
|
3671
|
+
if (pieces) {
|
|
3672
|
+
const host = pieces[0].querySelector('.twm-dt-detail');
|
|
3673
|
+
if (host) {
|
|
3674
|
+
this._drawDetail(tr, host, key);
|
|
3675
|
+
const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
|
|
3676
|
+
if (btn) btn.setAttribute('aria-controls', host.id);
|
|
3677
|
+
}
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
if (tr.parentNode) this._appendDetailIfOpen(tr, tr.nextSibling);
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
/** Open or close the detail of the row `tr` draws. Returns whether
|
|
3684
|
+
* anything changed. */
|
|
3685
|
+
_setRowElExpanded(tr, open, ev = null) {
|
|
3686
|
+
if (!this._detailOn() || tr.__rowIndex === undefined) return false;
|
|
3687
|
+
if (!this._rowHasDetail(tr.__row, tr.__rowIndex)) return false;
|
|
3688
|
+
const key = this._rowKey(tr.__row, tr.__rowIndex);
|
|
3689
|
+
if (key === null || this._expanded.has(key) === !!open) return false;
|
|
3690
|
+
if (open) this._expanded.add(key); else this._expanded.delete(key);
|
|
3691
|
+
this._syncDetailOnScreen(tr, key);
|
|
3692
|
+
this._afterDetailChange();
|
|
3693
|
+
try { this.config.onRowDetailToggle?.(tr.__rowIndex, tr.__row, !!open, key, ev); } catch (err) {
|
|
3694
|
+
console.error('[DataTable] onRowDetailToggle threw', err);
|
|
3695
|
+
}
|
|
3696
|
+
return true;
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
/** Make the row on screen agree with `_expanded` for its key. */
|
|
3700
|
+
_syncDetailOnScreen(tr, key) {
|
|
3701
|
+
const open = this._expanded.has(key);
|
|
3702
|
+
const btn = tr.querySelector(':scope > td > .twm-dt-detail-toggle');
|
|
3703
|
+
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
3704
|
+
if (open && !tr.__detail && tr.parentNode) {
|
|
3705
|
+
const ref = tr.nextSibling;
|
|
3706
|
+
for (const p of this._buildDetailPieces(tr, key)) tr.parentNode.insertBefore(p, ref);
|
|
3707
|
+
} else if (!open && tr.__detail) {
|
|
3708
|
+
this._removeDetailPieces(tr);
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
|
|
3712
|
+
/** Under `virtualize` an open detail is part of its row's height. */
|
|
3713
|
+
_afterDetailChange() {
|
|
3714
|
+
if (!this._virt) return;
|
|
3715
|
+
this._virtMeasureDetails([...this._virt.map.values()], { force: true });
|
|
3716
|
+
}
|
|
3717
|
+
|
|
3718
|
+
/** The detail's height for row `i` of the window's list: 0 closed, the
|
|
3719
|
+
* measured height open, three rows until it has been measured. */
|
|
3720
|
+
_detailHeightOf(row, rowIdx, base) {
|
|
3721
|
+
if (!this._rowHasDetail(row, rowIdx)) return 0;
|
|
3722
|
+
const key = this._rowKey(row, rowIdx);
|
|
3723
|
+
if (key === null || !this._expanded.has(key)) return 0;
|
|
3724
|
+
const h = this._detailHeights.get(key);
|
|
3725
|
+
return h > 0 ? h : base * 3;
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
/** Measure the open details among `trs` (client pixels, the zoom divided
|
|
3729
|
+
* out, as `_virtMeasure` does), and re-lay the window if one changed. */
|
|
3730
|
+
_virtMeasureDetails(trs, { force = false } = {}) {
|
|
3731
|
+
const v = this._virt;
|
|
3732
|
+
if (!v) return;
|
|
3733
|
+
const changed = this._virtReadDetailHeights(trs) || force;
|
|
3734
|
+
if (!changed) return;
|
|
3735
|
+
this._virtComputeOffsets();
|
|
3736
|
+
this._virtSpacers();
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
/** Read the open details drawn under `trs` into `_detailHeights`; true
|
|
3740
|
+
* when one moved by more than half a pixel. Recomputes nothing. */
|
|
3741
|
+
_virtReadDetailHeights(trs) {
|
|
3742
|
+
let changed = false;
|
|
3743
|
+
const zoom = this._tableEl?.currentCSSZoom || 1;
|
|
3744
|
+
for (const tr of trs) {
|
|
3745
|
+
const d = tr?.__detail?.[0];
|
|
3746
|
+
if (!d || !d.isConnected) continue;
|
|
3747
|
+
const h = d.getBoundingClientRect().height / zoom;
|
|
3748
|
+
if (!(h > 0)) continue;
|
|
3749
|
+
const key = this._rowKey(tr.__row, tr.__rowIndex);
|
|
3750
|
+
if (Math.abs((this._detailHeights.get(key) || 0) - h) > 0.5) {
|
|
3751
|
+
this._detailHeights.set(key, h);
|
|
3752
|
+
changed = true;
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
return changed;
|
|
3756
|
+
}
|
|
3757
|
+
|
|
3758
|
+
/**
|
|
3759
|
+
* Open (or close) the detail of the row whose key is `key` (`getRowKey`,
|
|
3760
|
+
* else the row's index). A row not on screen — another page, outside the
|
|
3761
|
+
* window, filtered out — opens when it is drawn.
|
|
3762
|
+
*
|
|
3763
|
+
* @param {*} key
|
|
3764
|
+
* @param {boolean} [open=true]
|
|
3765
|
+
* @returns {boolean} whether anything changed
|
|
3766
|
+
*/
|
|
3767
|
+
setRowExpanded(key, open = true) {
|
|
3768
|
+
if (key == null) return false;
|
|
3769
|
+
const k = String(key);
|
|
3770
|
+
if (this._expanded.has(k) === !!open) return false;
|
|
3771
|
+
if (open) this._expanded.add(k); else this._expanded.delete(k);
|
|
3772
|
+
const tr = this._rowElByKey(k);
|
|
3773
|
+
if (tr) {
|
|
3774
|
+
this._syncDetailOnScreen(tr, k);
|
|
3775
|
+
try { this.config.onRowDetailToggle?.(tr.__rowIndex, tr.__row, !!open, k, null); } catch (err) {
|
|
3776
|
+
console.error('[DataTable] onRowDetailToggle threw', err);
|
|
3777
|
+
}
|
|
3778
|
+
}
|
|
3779
|
+
if (this._virt) {
|
|
3780
|
+
this._virtComputeOffsets();
|
|
3781
|
+
this._virtSpacers();
|
|
3782
|
+
this._afterDetailChange();
|
|
3783
|
+
}
|
|
3784
|
+
return true;
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3787
|
+
/** Whether the row whose key is `key` has its detail open. */
|
|
3788
|
+
isRowExpanded(key) {
|
|
3789
|
+
return key != null && this._expanded.has(String(key));
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
/** The keys of the rows whose detail is open, as text. */
|
|
3793
|
+
getExpandedRows() {
|
|
3794
|
+
return [...this._expanded];
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
/** Close every detail. */
|
|
3798
|
+
collapseAllRows() {
|
|
3799
|
+
for (const k of [...this._expanded]) this.setRowExpanded(k, false);
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
/** The `<tr>` on screen for a key, or null. */
|
|
3803
|
+
_rowElByKey(key) {
|
|
3804
|
+
const tbody = this._tbodyEl;
|
|
3805
|
+
if (!tbody) return null;
|
|
3806
|
+
for (const tr of tbody.children) {
|
|
3807
|
+
if (tr.__rowIndex === undefined) continue;
|
|
3808
|
+
if (this._rowKey(tr.__row, tr.__rowIndex) === key) return tr;
|
|
3809
|
+
}
|
|
3810
|
+
return null;
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3814
|
+
// 0.7.0 — rows from a server (#33), the pager for an unknown total
|
|
3815
|
+
// (#35), refresh and follow (#36)
|
|
3816
|
+
// ─────────────────────────────────────────────────────────────────
|
|
3817
|
+
//
|
|
3818
|
+
// ONE QUESTION AT A TIME, AND ONLY THE NEWEST ANSWER. Every request takes
|
|
3819
|
+
// a ticket; an answer whose ticket is not the latest is dropped, and the
|
|
3820
|
+
// request it answered is aborted the moment a newer one is sent. Two
|
|
3821
|
+
// requests in flight from a fast typist otherwise race, and the loser
|
|
3822
|
+
// painting last is a list that does not match the box above it.
|
|
3823
|
+
|
|
3824
|
+
/** Rows that are the current page of something larger (`onPageChange`,
|
|
3825
|
+
* or 0.7.0's `dataSource`): drawn as given, never filtered, sorted or
|
|
3826
|
+
* sliced here. */
|
|
3827
|
+
_isServerSide() {
|
|
3828
|
+
return typeof this.config.onPageChange === 'function' || !!this.config.dataSource;
|
|
3829
|
+
}
|
|
3830
|
+
|
|
3831
|
+
/** `autoDispose`'s grace in ms: one task, `keepWhileDetached`'s number,
|
|
3832
|
+
* or Infinity for `true`. */
|
|
3833
|
+
_detachGrace() {
|
|
3834
|
+
const k = this.config.keepWhileDetached;
|
|
3835
|
+
if (k === true) return Infinity;
|
|
3836
|
+
if (typeof k === 'number' && Number.isFinite(k) && k > 0) return k;
|
|
3837
|
+
if (k === Infinity) return Infinity;
|
|
3838
|
+
return 0;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
_sourceState() {
|
|
3842
|
+
if (!this._source) {
|
|
3843
|
+
this._source = {
|
|
3844
|
+
started: false, ticket: 0, controller: null, inFlight: false,
|
|
3845
|
+
offset: 0, pendingOffset: 0, total: null, atLeast: 0, hasMore: false,
|
|
3846
|
+
query: null, loaded: false, pendingAppend: false,
|
|
3847
|
+
};
|
|
3848
|
+
}
|
|
3849
|
+
return this._source;
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
/** The column filters, as a query and `onFilterChange` carry them. */
|
|
3853
|
+
_filterList() {
|
|
3854
|
+
const headers = this.config.headers || [];
|
|
3855
|
+
const out = [];
|
|
3856
|
+
for (const [column, value] of this._state.filters) {
|
|
3857
|
+
if (typeof value === 'string' ? !value : this._isEmptyFilterValue(value)) continue;
|
|
3858
|
+
out.push({ column, header: headers[column], value });
|
|
3859
|
+
}
|
|
3860
|
+
return out.sort((a, b) => a.column - b.column);
|
|
3861
|
+
}
|
|
3862
|
+
|
|
3863
|
+
/** Rows per question: `paging.pageSize` when the source is paged, else
|
|
3864
|
+
* `pageSize` — one number for the query, `goToPage` and the pager. */
|
|
3865
|
+
_sourcePageSize() {
|
|
3866
|
+
const size = this._pagingConfig()?.pageSize ?? this.config.pageSize;
|
|
3867
|
+
return Math.max(1, size | 0);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
_buildQuery(offset, reason, limit = null) {
|
|
3871
|
+
const col = this._state.sortColumn;
|
|
3872
|
+
return {
|
|
3873
|
+
offset,
|
|
3874
|
+
limit: Number.isFinite(limit) && limit >= 1 ? Math.floor(limit) : this._sourcePageSize(),
|
|
3875
|
+
sort: col == null ? null : {
|
|
3876
|
+
column: col, header: (this.config.headers || [])[col], ascending: this._state.sortAscending,
|
|
3877
|
+
},
|
|
3878
|
+
filters: this._filterList(),
|
|
3879
|
+
reason,
|
|
3880
|
+
};
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
/**
|
|
3884
|
+
* Ask the source for the page at `offset`, and draw the answer if it is
|
|
3885
|
+
* still the newest by then. Resolves with the answer drawn, or undefined
|
|
3886
|
+
* when it was dropped (superseded, failed, or the table was disposed).
|
|
3887
|
+
*/
|
|
3888
|
+
async _sourceLoad({ offset = 0, reason = 'reload', keep = false, append = false, limit = null } = {}) {
|
|
3889
|
+
const fetch = this.config.dataSource;
|
|
3890
|
+
if (typeof fetch !== 'function') return undefined;
|
|
3891
|
+
const src = this._sourceState();
|
|
3892
|
+
src.started = true;
|
|
3893
|
+
const ticket = ++src.ticket;
|
|
3894
|
+
try { src.controller?.abort(); } catch (_) { /* gone */ }
|
|
3895
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
3896
|
+
src.controller = controller;
|
|
3897
|
+
const query = this._buildQuery(Math.max(0, offset | 0), reason, limit);
|
|
3898
|
+
src.query = query;
|
|
3899
|
+
src.inFlight = true;
|
|
3900
|
+
src.pendingOffset = query.offset;
|
|
3901
|
+
src.pendingAppend = append;
|
|
3902
|
+
// More rows under a scrolling list are said on the paging strip (busy),
|
|
3903
|
+
// not by covering the rows being read.
|
|
3904
|
+
if (!append) this.setLoading(true);
|
|
3905
|
+
let result;
|
|
3906
|
+
try {
|
|
3907
|
+
result = await fetch(query, { signal: controller?.signal });
|
|
3908
|
+
} catch (err) {
|
|
3909
|
+
if (ticket !== src.ticket) return undefined; // superseded or disposed
|
|
3910
|
+
src.inFlight = false;
|
|
3911
|
+
src.controller = null;
|
|
3912
|
+
src.pendingAppend = false;
|
|
3913
|
+
if (err?.name === 'AbortError' || controller?.signal?.aborted) {
|
|
3914
|
+
this.setLoading(false);
|
|
3915
|
+
return undefined;
|
|
3916
|
+
}
|
|
3917
|
+
// THE ROWS ON SCREEN STAY: a failed refresh does not blank a list a
|
|
3918
|
+
// person was reading. `setError` keeps them under its banner.
|
|
3919
|
+
this.setError(err);
|
|
3920
|
+
try { this.config.onLoadError?.(err, query); } catch (e) { console.error('[DataTable] onLoadError threw', e); }
|
|
3921
|
+
return undefined;
|
|
3922
|
+
}
|
|
3923
|
+
if (ticket !== src.ticket) return undefined; // a newer question is out
|
|
3924
|
+
src.inFlight = false;
|
|
3925
|
+
src.controller = null;
|
|
3926
|
+
src.pendingAppend = false;
|
|
3927
|
+
this._applySourceResult(result, query, { keep, append });
|
|
3928
|
+
try { this.config.onLoad?.(result, query); } catch (e) { console.error('[DataTable] onLoad threw', e); }
|
|
3929
|
+
return result;
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
_applySourceResult(result, query, { keep = false, append = false } = {}) {
|
|
3933
|
+
const src = this._sourceState();
|
|
3934
|
+
const r = Array.isArray(result) ? { rows: result } : (result || {});
|
|
3935
|
+
const answered = Array.isArray(r.rows) ? r.rows : [];
|
|
3936
|
+
const totalRaw = r.total ?? r.totalCount;
|
|
3937
|
+
const total = typeof totalRaw === 'number' && Number.isFinite(totalRaw) ? totalRaw : null;
|
|
3938
|
+
const hasMore = typeof r.hasMore === 'boolean' ? r.hasMore
|
|
3939
|
+
: total !== null ? query.offset + answered.length < total
|
|
3940
|
+
: answered.length >= query.limit;
|
|
3941
|
+
// `paging`, scrolling: the next rows go UNDER the ones held, and the
|
|
3942
|
+
// page held still starts where it started.
|
|
3943
|
+
let rows = append ? (this.config.rows || []).concat(answered) : answered;
|
|
3944
|
+
let rowsOffset = append ? (src.offset || 0) : query.offset;
|
|
3945
|
+
// …until the source proves it holds more than the soft limit: from then
|
|
3946
|
+
// on it is shown in PAGES (`resolvePaging`), and the rows the reader had
|
|
3947
|
+
// just reached ARE that page — nothing is asked again, and the reader
|
|
3948
|
+
// goes on from where they were reading.
|
|
3949
|
+
const pc = append ? this._pagingConfig() : null;
|
|
3950
|
+
if (pc && resolvePaging({
|
|
3951
|
+
mode: pc.mode, total, atLeast: r.atLeast, held: rowsOffset + rows.length, hasMore,
|
|
3952
|
+
softLimit: pc.softLimit, hardLimit: pc.hardLimit,
|
|
3953
|
+
}).mode === 'pages') {
|
|
3954
|
+
rows = answered;
|
|
3955
|
+
rowsOffset = query.offset;
|
|
3956
|
+
append = false;
|
|
3957
|
+
}
|
|
3958
|
+
// WITHOUT `getRowKey`, A DETAIL IS OPEN AT A PLACE, and a place in one
|
|
3959
|
+
// answer says nothing about which row stands there in the next: page 2's
|
|
3960
|
+
// first row is not page 1's, and a sort or a new arrival moves every
|
|
3961
|
+
// row. So an index-keyed detail stays open only where the new answer
|
|
3962
|
+
// has the SAME row (equal values) at its place; `expandedRows` applies
|
|
3963
|
+
// to the first answer as given. (The release review: a detail opened on
|
|
3964
|
+
// page 1 opened the same place on page 2, under another row.)
|
|
3965
|
+
if (src.loaded && this._detailOn() && this._expanded.size
|
|
3966
|
+
&& typeof this.config.getRowKey !== 'function') {
|
|
3967
|
+
const before = this.config.rows || [];
|
|
3968
|
+
const same = (a, b) => {
|
|
3969
|
+
if (a === b) return true;
|
|
3970
|
+
try { return JSON.stringify(a) === JSON.stringify(b); } catch (_) { return false; }
|
|
3971
|
+
};
|
|
3972
|
+
for (const k of [...this._expanded]) {
|
|
3973
|
+
const i = Number(k);
|
|
3974
|
+
if (!Number.isInteger(i) || i < 0 || i >= rows.length || !same(before[i], rows[i])) {
|
|
3975
|
+
this._expanded.delete(k);
|
|
3976
|
+
this._detailHeights.delete(k);
|
|
3977
|
+
}
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
// The page drawn — so the pager says where these rows ARE.
|
|
3981
|
+
src.offset = rowsOffset;
|
|
3982
|
+
src.total = total;
|
|
3983
|
+
src.atLeast = Number.isFinite(r.atLeast) ? r.atLeast : 0;
|
|
3984
|
+
src.hasMore = hasMore;
|
|
3985
|
+
src.loaded = true;
|
|
3986
|
+
if (r.filterCounts && typeof r.filterCounts === 'object') this.setFilterCounts(r.filterCounts);
|
|
3987
|
+
|
|
3988
|
+
const snap = keep ? this._snapshotView() : null;
|
|
3989
|
+
const kept = append ? this._tableWrapEl?.scrollTop || 0 : null;
|
|
3990
|
+
// Rows the last answer did not hold — marked as new (only by key: an
|
|
3991
|
+
// index says nothing about which row is which across two answers).
|
|
3992
|
+
if (snap && snap.keys && snap.keys.size && typeof this.config.getRowKey === 'function') {
|
|
3993
|
+
const fresh = new Set();
|
|
3994
|
+
rows.forEach((row, i) => {
|
|
3995
|
+
const k = this._rowKey(row, i);
|
|
3996
|
+
if (k !== null && !snap.keys.has(k)) fresh.add(k);
|
|
3997
|
+
});
|
|
3998
|
+
this._newKeys = fresh.size ? fresh : null;
|
|
3999
|
+
} else {
|
|
4000
|
+
this._newKeys = null;
|
|
4001
|
+
}
|
|
4002
|
+
// More rows under the same ones: the rows a person chose stay chosen.
|
|
4003
|
+
const chosen = append && this._state.selected.size
|
|
4004
|
+
? { sel: [...this._state.selected], anchor: this._state.anchorIndex } : null;
|
|
4005
|
+
this.setData({ rows });
|
|
4006
|
+
this._newKeys = null;
|
|
4007
|
+
if (chosen) {
|
|
4008
|
+
for (const i of chosen.sel) this._state.selected.add(i);
|
|
4009
|
+
this._state.anchorIndex = chosen.anchor;
|
|
4010
|
+
this._updateRowSelection();
|
|
4011
|
+
}
|
|
4012
|
+
if (snap) this._restoreView(snap);
|
|
4013
|
+
else if (kept !== null) this._scrollBodyTo(kept); // more rows: the reader stays put
|
|
4014
|
+
else this._scrollBodyTo(0); // a new question starts at the top of its answer
|
|
4015
|
+
}
|
|
4016
|
+
|
|
4017
|
+
/** What a refresh must put back: the keys on screen, the selection by
|
|
4018
|
+
* key, and the first row a reader can see with where it stood. */
|
|
4019
|
+
_snapshotView() {
|
|
4020
|
+
const rows = this.config.rows || [];
|
|
4021
|
+
const keyed = typeof this.config.getRowKey === 'function';
|
|
4022
|
+
const keys = keyed ? new Set(rows.map((row, i) => this._rowKey(row, i))) : null;
|
|
4023
|
+
const selectedKeys = keyed
|
|
4024
|
+
? this.getSelection().map((i) => this._rowKey(rows[i], i)).filter((k) => k !== null)
|
|
4025
|
+
: null;
|
|
4026
|
+
const wrap = this._tableWrapEl;
|
|
4027
|
+
let anchor = null;
|
|
4028
|
+
const scrollTop = wrap?.scrollTop || 0;
|
|
4029
|
+
if (wrap && scrollTop > 0 && this._tbodyEl) {
|
|
4030
|
+
const zoom = this._tableEl?.currentCSSZoom || 1;
|
|
4031
|
+
const top = wrap.getBoundingClientRect().top;
|
|
4032
|
+
for (const tr of this._tbodyEl.children) {
|
|
4033
|
+
if (tr.__rowIndex === undefined) continue;
|
|
4034
|
+
const box = tr.getBoundingClientRect();
|
|
4035
|
+
if (box.bottom > top + 0.5) {
|
|
4036
|
+
anchor = { key: this._rowKey(tr.__row, tr.__rowIndex), delta: (box.top - top) / zoom };
|
|
4037
|
+
break;
|
|
4038
|
+
}
|
|
4039
|
+
}
|
|
4040
|
+
}
|
|
4041
|
+
return { keys, selectedKeys, anchor, scrollTop };
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
_restoreView(snap) {
|
|
4045
|
+
// The selection, by key: the rows a person chose are still chosen
|
|
4046
|
+
// wherever the new answer put them.
|
|
4047
|
+
if (snap.selectedKeys && snap.selectedKeys.length) {
|
|
4048
|
+
const want = new Set(snap.selectedKeys);
|
|
4049
|
+
const sel = this._state.selected;
|
|
4050
|
+
(this.config.rows || []).forEach((row, i) => {
|
|
4051
|
+
if (want.has(this._rowKey(row, i))) sel.add(i);
|
|
4052
|
+
});
|
|
4053
|
+
if (sel.size) {
|
|
4054
|
+
this._state.anchorIndex = [...sel][0];
|
|
4055
|
+
this._updateRowSelection();
|
|
4056
|
+
this._notifySelectionChange();
|
|
4057
|
+
}
|
|
4058
|
+
}
|
|
4059
|
+
// THE SCROLL POSITION: a reader at the top sees what arrived (that is
|
|
4060
|
+
// following); a reader scrolled down keeps the rows they were reading
|
|
4061
|
+
// where they were — new rows above push the scroll, not the rows.
|
|
4062
|
+
const wrap = this._tableWrapEl;
|
|
4063
|
+
if (!wrap || !snap.anchor || snap.anchor.key === null) return;
|
|
4064
|
+
const rows = this.config.rows || [];
|
|
4065
|
+
const idx = rows.findIndex((row, i) => this._rowKey(row, i) === snap.anchor.key);
|
|
4066
|
+
if (idx < 0) return;
|
|
4067
|
+
const zoom = this._tableEl?.currentCSSZoom || 1;
|
|
4068
|
+
if (this._virt) {
|
|
4069
|
+
const local = this._virtLocalOf(idx);
|
|
4070
|
+
if (local < 0) return;
|
|
4071
|
+
this._scrollBodyTo(Math.max(0, this._virt.offsets[local] - snap.anchor.delta));
|
|
4072
|
+
return;
|
|
4073
|
+
}
|
|
4074
|
+
const tr = this.getRowElement(idx);
|
|
4075
|
+
if (!tr) return;
|
|
4076
|
+
const now = (tr.getBoundingClientRect().top - wrap.getBoundingClientRect().top) / zoom;
|
|
4077
|
+
const shift = now - snap.anchor.delta;
|
|
4078
|
+
if (Math.abs(shift) >= 0.5) this._scrollBodyTo(wrap.scrollTop + shift);
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
_scrollBodyTo(top) {
|
|
4082
|
+
const wrap = this._tableWrapEl;
|
|
4083
|
+
if (!wrap) return;
|
|
4084
|
+
if (Math.abs((wrap.scrollTop || 0) - top) >= 0.5) wrap.scrollTop = top;
|
|
4085
|
+
if (this._virt) this._virtUpdate({ force: true, top });
|
|
4086
|
+
}
|
|
4087
|
+
|
|
4088
|
+
/** Go to the page at `offset` (paging back stops following). */
|
|
4089
|
+
_sourceGoTo(offset) {
|
|
4090
|
+
const next = Math.max(0, offset | 0);
|
|
4091
|
+
// PAGING AWAY TURNS FOLLOWING OFF, and coming back does not turn it on
|
|
4092
|
+
// again by itself: a reader who left the tail to read an old page is
|
|
4093
|
+
// never pulled back to it by the next tick.
|
|
4094
|
+
if (this._following && next > 0) this.setFollow(false);
|
|
4095
|
+
return this._sourceLoad({ offset: next, reason: 'page' });
|
|
4096
|
+
}
|
|
4097
|
+
|
|
4098
|
+
/**
|
|
4099
|
+
* `paging` over a `dataSource`, scrolling: the rows after those held, under
|
|
4100
|
+
* them — never past the hard limit. Scrolling down to ask for them is
|
|
4101
|
+
* paging away, so it turns following off as a page turn does; rows asked
|
|
4102
|
+
* for because the ones held do not fill the box are not (the reader is
|
|
4103
|
+
* still at the top).
|
|
4104
|
+
*/
|
|
4105
|
+
_sourceLoadMore() {
|
|
4106
|
+
const held = this.config.rows?.length || 0;
|
|
4107
|
+
const hard = this.pagingState?.hardLimit ?? PAGING_HARD_LIMIT;
|
|
4108
|
+
const limit = Math.min(this._sourcePageSize(), hard - held);
|
|
4109
|
+
if (limit < 1) return Promise.resolve(undefined);
|
|
4110
|
+
if (this._following && (this._tableWrapEl?.scrollTop || 0) > 0) this.setFollow(false);
|
|
4111
|
+
const offset = (this._source?.offset || 0) + held;
|
|
4112
|
+
return this._sourceLoad({ offset, limit, reason: 'more', append: true });
|
|
4113
|
+
}
|
|
4114
|
+
|
|
4115
|
+
/** The strip's *Load all rows*: ONE question for every row left under the
|
|
4116
|
+
* hard limit (`reason: 'all'`); the source answers as many as it will, and
|
|
4117
|
+
* says with `hasMore` whether there are still more. */
|
|
4118
|
+
_sourceLoadAll() {
|
|
4119
|
+
const held = this.config.rows?.length || 0;
|
|
4120
|
+
const hard = this.pagingState?.hardLimit ?? PAGING_HARD_LIMIT;
|
|
4121
|
+
if (hard - held < 1) return Promise.resolve(undefined);
|
|
4122
|
+
if (this._following) this.setFollow(false);
|
|
4123
|
+
const offset = (this._source?.offset || 0) + held;
|
|
4124
|
+
return this._sourceLoad({ offset, limit: hard - held, reason: 'all', append: true });
|
|
4125
|
+
}
|
|
4126
|
+
|
|
4127
|
+
/** The strip's Pages / Scroll switch: the table switches, tells the
|
|
4128
|
+
* consumer (`paging.onModeChange`), and asks again from the start. */
|
|
4129
|
+
_sourceSetMode(mode) {
|
|
4130
|
+
const next = mode === 'pages' ? 'pages' : 'scroll';
|
|
4131
|
+
const p = this.config.paging;
|
|
4132
|
+
this.config.paging = { ...p, mode: next };
|
|
4133
|
+
try { p.onModeChange?.(next); } catch (err) { console.error('[DataTable] onModeChange threw', err); }
|
|
4134
|
+
return this._sourceLoad({ offset: 0, reason: 'mode' });
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
/**
|
|
4138
|
+
* Ask the source again from its FIRST page — for a consumer whose own
|
|
4139
|
+
* filters (outside the table) changed. Under `dataSource` only; resolves
|
|
4140
|
+
* with the answer drawn (undefined when it was superseded or failed).
|
|
4141
|
+
*/
|
|
4142
|
+
reload() {
|
|
4143
|
+
if (!this.config.dataSource) return Promise.resolve(undefined);
|
|
4144
|
+
return this._sourceLoad({ offset: 0, reason: 'reload' });
|
|
4145
|
+
}
|
|
4146
|
+
|
|
4147
|
+
/**
|
|
4148
|
+
* Read the CURRENT page again, in place: the scroll position (a reader at
|
|
4149
|
+
* the top sees what arrived; one scrolled down keeps the rows they were
|
|
4150
|
+
* reading where they were), the selection by key, the open row and the
|
|
4151
|
+
* open details all stay; rows the last answer did not hold are drawn
|
|
4152
|
+
* `twm-dt-row--new` (needs `getRowKey`). What `follow` calls on its tick.
|
|
4153
|
+
* Without `dataSource`, it only draws again.
|
|
4154
|
+
*/
|
|
4155
|
+
refresh() {
|
|
4156
|
+
if (!this.config.dataSource) {
|
|
4157
|
+
if (this._wrapperEl) this.render();
|
|
4158
|
+
return Promise.resolve(undefined);
|
|
4159
|
+
}
|
|
4160
|
+
const src = this._sourceState();
|
|
4161
|
+
// `paging`, scrolling: the rows HELD are read again, in one question
|
|
4162
|
+
// from where they start — a list scrolled to row 300 is still 300 rows
|
|
4163
|
+
// after the tick. (Following stops once the reader scrolls down for
|
|
4164
|
+
// more, so a following list stays short.)
|
|
4165
|
+
if (this.pagingState?.mode === 'scroll') {
|
|
4166
|
+
const held = this.config.rows?.length || 0;
|
|
4167
|
+
return this._sourceLoad({
|
|
4168
|
+
offset: src.offset || 0, limit: Math.max(this._sourcePageSize(), held),
|
|
4169
|
+
reason: 'refresh', keep: true,
|
|
4170
|
+
});
|
|
4171
|
+
}
|
|
4172
|
+
const offset = src.inFlight && !src.pendingAppend ? src.pendingOffset : src.offset;
|
|
4173
|
+
return this._sourceLoad({ offset, reason: 'refresh', keep: true });
|
|
4174
|
+
}
|
|
4175
|
+
|
|
4176
|
+
/** The last query sent to `dataSource`, or null. */
|
|
4177
|
+
getQuery() {
|
|
4178
|
+
return this._source?.query || null;
|
|
4179
|
+
}
|
|
4180
|
+
|
|
4181
|
+
/** What the last answer said about the whole: `{offset, total, hasMore}`. */
|
|
4182
|
+
getPageInfo() {
|
|
4183
|
+
const s = this._source;
|
|
4184
|
+
return s ? { offset: s.offset, total: s.total, hasMore: s.hasMore } : null;
|
|
4185
|
+
}
|
|
4186
|
+
|
|
4187
|
+
/**
|
|
4188
|
+
* Turn following on or off (`follow`). On, the first page is read again
|
|
4189
|
+
* every `follow.interval` ms while the table is on it and in the page.
|
|
4190
|
+
*/
|
|
4191
|
+
setFollow(on) {
|
|
4192
|
+
const next = !!on;
|
|
4193
|
+
if (next === this._following) return;
|
|
4194
|
+
this._following = next;
|
|
4195
|
+
if (next) this._armFollow(); else this._disarmFollow();
|
|
4196
|
+
try { this.config.onFollowChange?.(next); } catch (err) { console.error('[DataTable] onFollowChange threw', err); }
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
isFollowing() {
|
|
4200
|
+
return this._following;
|
|
4201
|
+
}
|
|
4202
|
+
|
|
4203
|
+
_followInterval() {
|
|
4204
|
+
const f = this.config.follow;
|
|
4205
|
+
const ms = f && typeof f === 'object' ? Number(f.interval) : NaN;
|
|
4206
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 10_000;
|
|
4207
|
+
}
|
|
4208
|
+
|
|
4209
|
+
/** The tick holds the table WEAKLY: a follow timer must never be what keeps
|
|
4210
|
+
* a table nobody holds alive (`keepWhileDetached: true`). */
|
|
4211
|
+
_armFollow() {
|
|
4212
|
+
this._disarmFollow();
|
|
4213
|
+
if (!this._following || !this.config.dataSource || !this._wrapperEl) return;
|
|
4214
|
+
const ref = weakRef(this);
|
|
4215
|
+
const id = setInterval(() => {
|
|
4216
|
+
const t = ref.deref();
|
|
4217
|
+
if (!t || t._followTimer !== id) { clearInterval(id); return; }
|
|
4218
|
+
const src = t._source;
|
|
4219
|
+
if (!t._following || !src || src.inFlight) return;
|
|
4220
|
+
if ((src.offset || 0) !== 0) return; // only on the first page
|
|
4221
|
+
if (!t._wrapperEl?.isConnected) return; // not while out of the page
|
|
4222
|
+
if (t._readerIsBusy()) return; // not under a reader (below)
|
|
4223
|
+
t.refresh();
|
|
4224
|
+
}, this._followInterval());
|
|
4225
|
+
this._followTimer = id;
|
|
4226
|
+
}
|
|
4227
|
+
|
|
4228
|
+
_disarmFollow() {
|
|
4229
|
+
if (this._followTimer) clearInterval(this._followTimer);
|
|
4230
|
+
this._followTimer = null;
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
/** A TICK WAITS FOR A READER. A refresh redraws every row and every open
|
|
4234
|
+
* detail, so a tick that came while somebody was selecting text in the
|
|
4235
|
+
* table (a stack trace to copy) or working inside an open detail took the
|
|
4236
|
+
* text out from under the selection and the focus out of the detail —
|
|
4237
|
+
* every interval, for as long as they stayed. The tick is skipped
|
|
4238
|
+
* instead, and following resumes on the next one after (the release
|
|
4239
|
+
* review). `refresh()` called by the consumer is not a tick and does not
|
|
4240
|
+
* wait. */
|
|
4241
|
+
_readerIsBusy() {
|
|
4242
|
+
const wrap = this._tableWrapEl;
|
|
4243
|
+
const doc = wrap?.ownerDocument;
|
|
4244
|
+
if (!wrap || !doc) return false;
|
|
4245
|
+
const active = doc.activeElement;
|
|
4246
|
+
if (active && active !== wrap && wrap.contains(active)
|
|
4247
|
+
&& active.closest?.('.twm-dt-detail')) return true;
|
|
4248
|
+
try {
|
|
4249
|
+
const sel = doc.defaultView?.getSelection?.();
|
|
4250
|
+
if (sel && !sel.isCollapsed && sel.rangeCount > 0) {
|
|
4251
|
+
for (let i = 0; i < sel.rangeCount; i++) {
|
|
4252
|
+
const r = sel.getRangeAt(i);
|
|
4253
|
+
if (wrap.contains(r.startContainer) || wrap.contains(r.endContainer)) return true;
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
} catch (_) { /* no selection API */ }
|
|
4257
|
+
return false;
|
|
4258
|
+
}
|
|
4259
|
+
|
|
4260
|
+
// ── the pager (#35) ────────────────────────────────────────────────
|
|
4261
|
+
|
|
4262
|
+
_usesPager070() {
|
|
4263
|
+
const c = this.config;
|
|
4264
|
+
return !!c.dataSource || !!c.pagerLabels || c.maxJumpOffset != null
|
|
4265
|
+
|| (typeof c.onPageChange === 'function' && c.totalCount == null && typeof c.hasMore === 'boolean');
|
|
4266
|
+
}
|
|
4267
|
+
|
|
4268
|
+
_pagerLabel(name) {
|
|
4269
|
+
const labels = this.config.pagerLabels || {};
|
|
4270
|
+
return labels[name];
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
_pagerRefusalTooDeep() {
|
|
4274
|
+
const max = this.config.maxJumpOffset;
|
|
4275
|
+
const fn = this._pagerLabel('tooDeep');
|
|
4276
|
+
if (typeof fn === 'function') return String(fn(max));
|
|
4277
|
+
if (typeof fn === 'string') return fn;
|
|
4278
|
+
return `Past ${Number(max).toLocaleString()} rows. Narrow the filters first.`;
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4281
|
+
_pagerButton(icon, name, tip, disabled, onClick, refusal = null) {
|
|
4282
|
+
const label = this._pagerLabel(name);
|
|
4283
|
+
const btn = this._createPaginationBtn(icon, typeof label === 'string' ? label : tip,
|
|
4284
|
+
disabled || !!refusal, onClick);
|
|
4285
|
+
btn.dataset.pager = name;
|
|
4286
|
+
if (typeof label === 'string' && label) {
|
|
4287
|
+
const span = document.createElement('span');
|
|
4288
|
+
span.className = 'twm-pagination-btn__label';
|
|
4289
|
+
span.textContent = label;
|
|
4290
|
+
btn.appendChild(span);
|
|
4291
|
+
btn.classList.add('twm-pagination-btn--labelled');
|
|
4292
|
+
btn.style.padding = '1px 6px 1px 3px';
|
|
4293
|
+
// NEVER NARROWER THAN ITS WORD. The pager's buttons carry a
|
|
4294
|
+
// `min-width` sized for a glyph (22px, `!important` in the compact
|
|
4295
|
+
// skin), and in a narrow tile the flex row shrank a labelled one
|
|
4296
|
+
// to it and clipped its word. Inline and `important`, because it
|
|
4297
|
+
// is the one declaration that must beat both skins.
|
|
4298
|
+
btn.style.setProperty('min-width', 'max-content', 'important');
|
|
4299
|
+
btn.style.flexShrink = '0';
|
|
4300
|
+
}
|
|
4301
|
+
if (refusal) {
|
|
4302
|
+
// The reason is ON the control, where a person looks and a test can
|
|
4303
|
+
// read it — not only in a tooltip.
|
|
4304
|
+
btn.dataset.refusal = refusal;
|
|
4305
|
+
btn.setAttribute('data-tooltip', refusal);
|
|
4306
|
+
btn.title = refusal;
|
|
4307
|
+
}
|
|
4308
|
+
return btn;
|
|
4309
|
+
}
|
|
4310
|
+
|
|
4311
|
+
/**
|
|
4312
|
+
* THE PAGE ARITHMETIC, ONCE — read by the pager above the rows (0.7.0) and
|
|
4313
|
+
* by the `paging` strip under them (0.6.2), which each worked it out for
|
|
4314
|
+
* themselves. `held` is the rows drawn on this page; `total` a number or
|
|
4315
|
+
* null (unknown, when `hasMore` says whether there is a next page).
|
|
4316
|
+
*
|
|
4317
|
+
* @returns {{known: boolean, hasPrev: boolean, hasNext: boolean, page: number,
|
|
4318
|
+
* pages: number|null, lastOffset: number|null, lastRefusal: string|null}}
|
|
4319
|
+
*/
|
|
4320
|
+
_pageNav({ offset = 0, held = 0, total = null, hasMore = false, pageSize }) {
|
|
4321
|
+
const size = Math.max(1, pageSize | 0);
|
|
4322
|
+
const known = typeof total === 'number' && Number.isFinite(total);
|
|
4323
|
+
const lastOffset = known ? Math.floor(Math.max(0, total - 1) / size) * size : null;
|
|
4324
|
+
const maxJump = Number.isFinite(this.config.maxJumpOffset) ? this.config.maxJumpOffset : null;
|
|
4325
|
+
let lastRefusal = null;
|
|
4326
|
+
if (!known) {
|
|
4327
|
+
const words = this._pagerLabel('noLast');
|
|
4328
|
+
lastRefusal = typeof words === 'string' ? words : 'The total is not known, so there is no last page.';
|
|
4329
|
+
} else if (maxJump !== null && lastOffset > maxJump) {
|
|
4330
|
+
lastRefusal = this._pagerRefusalTooDeep();
|
|
4331
|
+
}
|
|
4332
|
+
return {
|
|
4333
|
+
known,
|
|
4334
|
+
hasPrev: offset > 0,
|
|
4335
|
+
hasNext: known ? offset + held < total : !!hasMore,
|
|
4336
|
+
page: Math.floor(offset / size) + 1,
|
|
4337
|
+
pages: known ? Math.max(1, Math.ceil(total / size)) : null,
|
|
4338
|
+
lastOffset,
|
|
4339
|
+
lastRefusal,
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
/**
|
|
4344
|
+
* The pager for any mode, with the two things the 0.6 strip could not do:
|
|
4345
|
+
* an UNKNOWN total (no "of N", no last page, Next until a short page) and
|
|
4346
|
+
* a REFUSED deep jump (`maxJumpOffset`). For a known total its words are
|
|
4347
|
+
* the 0.6 strip's.
|
|
4348
|
+
*/
|
|
4349
|
+
_createPager070() {
|
|
4350
|
+
const c = this.config;
|
|
4351
|
+
const { rows, pageSize } = c;
|
|
4352
|
+
const source = !!c.dataSource;
|
|
4353
|
+
const server = !source && typeof c.onPageChange === 'function';
|
|
4354
|
+
const src = this._source || { offset: 0, total: null, hasMore: false };
|
|
4355
|
+
const offset = source ? src.offset : server ? (c.offset || 0) : this._state.offset;
|
|
4356
|
+
let total;
|
|
4357
|
+
let hasMoreKnown;
|
|
4358
|
+
if (source) {
|
|
4359
|
+
total = src.total;
|
|
4360
|
+
hasMoreKnown = src.hasMore;
|
|
4361
|
+
} else if (server) {
|
|
4362
|
+
const unknown = c.totalCount == null && typeof c.hasMore === 'boolean';
|
|
4363
|
+
total = unknown ? null : (c.totalCount || rows.length);
|
|
4364
|
+
hasMoreKnown = unknown ? c.hasMore : null;
|
|
4365
|
+
} else {
|
|
4366
|
+
total = this._getProcessedRows().length;
|
|
4367
|
+
hasMoreKnown = null;
|
|
4368
|
+
}
|
|
4369
|
+
const known = typeof total === 'number' && Number.isFinite(total);
|
|
4370
|
+
const isFiltered = !source && !server && this._state.filters.size > 0;
|
|
4371
|
+
const unfilteredTotal = !source && !server ? rows.length : total;
|
|
4372
|
+
const drawn = source || server ? rows.length : Math.max(0, Math.min(pageSize, total - offset));
|
|
4373
|
+
const pn = this._pageNav({ offset, held: drawn, total, hasMore: hasMoreKnown, pageSize });
|
|
4374
|
+
const { hasPrev, hasNext, page } = pn;
|
|
4375
|
+
const totalPages = pn.pages;
|
|
4376
|
+
if (known && totalPages <= 1 && !isFiltered && !hasPrev) return null;
|
|
4377
|
+
if (!known && !hasPrev && !hasNext) return null;
|
|
4378
|
+
const maxJump = Number.isFinite(c.maxJumpOffset) ? c.maxJumpOffset : null;
|
|
4379
|
+
|
|
4380
|
+
const go = (newOffset) => {
|
|
4381
|
+
if (source) { this._sourceGoTo(newOffset); return; }
|
|
4382
|
+
if (server) { this._awaitWithSpinner(c.onPageChange(newOffset, pageSize)); return; }
|
|
4383
|
+
this._state.offset = newOffset;
|
|
4384
|
+
this.render();
|
|
4385
|
+
};
|
|
4386
|
+
|
|
4387
|
+
const el = document.createElement('div');
|
|
4388
|
+
el.className = 'twm-pagination-controls';
|
|
4389
|
+
if (!known) el.classList.add('twm-pagination-controls--unknown-total');
|
|
4390
|
+
el.style.cssText = 'display:flex; align-items:center; gap:6px; padding:2px 8px; border-bottom:1px solid #2a2a2a; font-size:11px;';
|
|
4391
|
+
|
|
4392
|
+
const info = document.createElement('span');
|
|
4393
|
+
info.className = 'twm-pagination-info';
|
|
4394
|
+
info.style.cssText = 'color:#aaa; white-space:nowrap;';
|
|
4395
|
+
const from = (offset + 1).toLocaleString();
|
|
4396
|
+
const to = (offset + drawn).toLocaleString();
|
|
4397
|
+
if (drawn === 0) {
|
|
4398
|
+
info.textContent = hasPrev ? `Page ${page} · no rows`
|
|
4399
|
+
: isFiltered ? `0 of ${unfilteredTotal.toLocaleString()} rows match` : 'No rows';
|
|
4400
|
+
} else if (!known) {
|
|
4401
|
+
info.textContent = `Rows ${from}–${to}`;
|
|
4402
|
+
} else if (isFiltered) {
|
|
4403
|
+
info.textContent = `Rows ${from}–${to} of ${total.toLocaleString()} (${unfilteredTotal.toLocaleString()} total)`;
|
|
4404
|
+
} else {
|
|
4405
|
+
info.textContent = `Rows ${from}–${to} of ${total.toLocaleString()}`;
|
|
4406
|
+
}
|
|
4407
|
+
|
|
4408
|
+
const spacer = document.createElement('div');
|
|
4409
|
+
spacer.style.flex = '1';
|
|
4410
|
+
|
|
4411
|
+
const firstBtn = this._pagerButton('first_page', 'first', 'First page', !hasPrev, () => go(0));
|
|
4412
|
+
const prevBtn = this._pagerButton('chevron_left', 'prev', 'Previous page', !hasPrev,
|
|
4413
|
+
() => go(Math.max(0, offset - pageSize)));
|
|
4414
|
+
const pageInfo = document.createElement('span');
|
|
4415
|
+
pageInfo.className = 'twm-pagination-page';
|
|
4416
|
+
pageInfo.style.cssText = 'color:#aaa; font-size:10px; min-width:72px; text-align:center;';
|
|
4417
|
+
pageInfo.textContent = known ? `Page ${page} of ${totalPages.toLocaleString()}` : `Page ${page}`;
|
|
4418
|
+
const nextBtn = this._pagerButton('chevron_right', 'next', 'Next page', !hasNext,
|
|
4419
|
+
() => go(offset + pageSize));
|
|
4420
|
+
const { lastOffset, lastRefusal } = pn;
|
|
4421
|
+
const lastBtn = this._pagerButton('last_page', 'last', 'Last page',
|
|
4422
|
+
!hasNext || lastOffset === offset, () => {
|
|
4423
|
+
if (lastOffset !== null) go(lastOffset);
|
|
4424
|
+
}, lastRefusal);
|
|
4425
|
+
|
|
4426
|
+
const jumpInput = document.createElement('input');
|
|
4427
|
+
jumpInput.type = 'number';
|
|
4428
|
+
jumpInput.className = 'twm-pagination-input';
|
|
4429
|
+
jumpInput.style.cssText = 'width:54px; padding:1px 4px; border:1px solid #444; background:#2a2a2a; color:#ccc; font-size:10px;';
|
|
4430
|
+
jumpInput.min = '1';
|
|
4431
|
+
if (known) jumpInput.max = String(total);
|
|
4432
|
+
jumpInput.placeholder = 'Row #';
|
|
4433
|
+
const jumpBtn = this._createPaginationBtn(null, 'Go to row', false, () => {
|
|
4434
|
+
const target = Math.floor(Number(jumpInput.value));
|
|
4435
|
+
if (!Number.isFinite(target) || target < 1) return;
|
|
4436
|
+
const row = known ? Math.min(target, Math.max(1, total)) : target;
|
|
4437
|
+
const newOffset = Math.floor((row - 1) / pageSize) * pageSize;
|
|
4438
|
+
if (maxJump !== null && newOffset > maxJump) {
|
|
4439
|
+
// Refused, and says so where the person is looking.
|
|
4440
|
+
const why = this._pagerRefusalTooDeep();
|
|
4441
|
+
jumpInput.setAttribute('aria-invalid', 'true');
|
|
4442
|
+
jumpInput.title = why;
|
|
4443
|
+
jumpInput.dataset.refusal = why;
|
|
4444
|
+
return;
|
|
4445
|
+
}
|
|
4446
|
+
jumpInput.removeAttribute('aria-invalid');
|
|
4447
|
+
delete jumpInput.dataset.refusal;
|
|
4448
|
+
if (server && typeof c.onJumpToRow === 'function') { c.onJumpToRow(row - 1); return; }
|
|
4449
|
+
go(newOffset);
|
|
4450
|
+
}, 'Go');
|
|
4451
|
+
jumpBtn.dataset.pager = 'go';
|
|
4452
|
+
|
|
4453
|
+
el.append(info, spacer, firstBtn, prevBtn, pageInfo, nextBtn, lastBtn, jumpInput, jumpBtn);
|
|
4454
|
+
if (c.showExportButton) {
|
|
4455
|
+
el.appendChild(this._createPaginationBtn('download', 'Download visible rows as CSV', false,
|
|
4456
|
+
() => this.downloadCSV('data.csv')));
|
|
4457
|
+
}
|
|
4458
|
+
return el;
|
|
4459
|
+
}
|
|
4460
|
+
|
|
4461
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4462
|
+
// 0.7.0 — filter controls that are not text boxes (#37)
|
|
4463
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4464
|
+
|
|
4465
|
+
/** The spec for a column, normalised: null for the 0.4 text box. */
|
|
4466
|
+
_filterKindFor(colIdx, header) {
|
|
4467
|
+
const fk = this.config.filterKinds;
|
|
4468
|
+
if (fk == null) return null;
|
|
4469
|
+
let spec;
|
|
4470
|
+
if (typeof fk === 'function') {
|
|
4471
|
+
try { spec = fk(colIdx, header); } catch (err) {
|
|
4472
|
+
console.error('[DataTable] filterKinds threw', err);
|
|
4473
|
+
spec = null;
|
|
4474
|
+
}
|
|
4475
|
+
} else {
|
|
4476
|
+
spec = perColumn(fk, colIdx);
|
|
4477
|
+
}
|
|
4478
|
+
if (spec == null || spec === 'text' || spec === false) return null;
|
|
4479
|
+
if (typeof spec === 'function') return { kind: 'custom', create: spec };
|
|
4480
|
+
if (typeof spec === 'object') {
|
|
4481
|
+
if (spec.kind === 'text') return null;
|
|
4482
|
+
if (spec.kind === 'chips') return spec;
|
|
4483
|
+
if (typeof spec.create === 'function') return { ...spec, kind: spec.kind || 'custom' };
|
|
4484
|
+
}
|
|
4485
|
+
if (spec === 'chips') return null; // chips need options
|
|
4486
|
+
return null;
|
|
4487
|
+
}
|
|
4488
|
+
|
|
4489
|
+
/** The column's control — built once, then the same element every render. */
|
|
4490
|
+
_filterControlFor(colIdx, header) {
|
|
4491
|
+
const spec = this._filterKindFor(colIdx, header);
|
|
4492
|
+
if (!spec) return null;
|
|
4493
|
+
const have = this._filterControls.get(colIdx);
|
|
4494
|
+
if (have && have.kind === spec.kind && have.specKey === (spec.options || spec.create)) {
|
|
4495
|
+
return have.control;
|
|
4496
|
+
}
|
|
4497
|
+
if (have) this._destroyOneControl(have.control);
|
|
4498
|
+
const value = this._state.filters.get(colIdx) ?? null;
|
|
4499
|
+
const ctx = {
|
|
4500
|
+
column: colIdx,
|
|
4501
|
+
header,
|
|
4502
|
+
value,
|
|
4503
|
+
counts: this._filterCounts.get(colIdx) ?? null,
|
|
4504
|
+
table: this,
|
|
4505
|
+
setValue: (v) => this.setFilter(colIdx, v),
|
|
4506
|
+
};
|
|
4507
|
+
let control = null;
|
|
4508
|
+
try {
|
|
4509
|
+
control = spec.kind === 'chips' ? createChipsFilter(ctx, spec) : spec.create(ctx);
|
|
4510
|
+
} catch (err) {
|
|
4511
|
+
console.error('[DataTable] filter control threw while being built', err);
|
|
4512
|
+
control = null;
|
|
4513
|
+
}
|
|
4514
|
+
if (typeof Node !== 'undefined' && control instanceof Node) control = { el: control };
|
|
4515
|
+
if (!control || !control.el) return null;
|
|
4516
|
+
control.el.classList?.add('twm-dt-filter-control');
|
|
4517
|
+
this._filterControls.set(colIdx, {
|
|
4518
|
+
kind: spec.kind, specKey: spec.options || spec.create, control,
|
|
4519
|
+
match: typeof control.match === 'function' ? control.match
|
|
4520
|
+
: spec.kind === 'chips' ? chipsMatch(spec) : null,
|
|
4521
|
+
});
|
|
4522
|
+
return control;
|
|
4523
|
+
}
|
|
4524
|
+
|
|
4525
|
+
/** The client-side rule for a column with a control, or null. */
|
|
4526
|
+
_filterMatcher(colIdx) {
|
|
4527
|
+
if (!this._filterControls.size && this.config.filterKinds == null) return null;
|
|
4528
|
+
const entry = this._filterControls.get(colIdx);
|
|
4529
|
+
if (entry) return entry.match || defaultControlMatch;
|
|
4530
|
+
// A control not yet built (no render yet, a restore): the spec decides.
|
|
4531
|
+
const spec = this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
|
|
4532
|
+
if (!spec) return null;
|
|
4533
|
+
return spec.kind === 'chips' ? chipsMatch(spec) : (spec.match || defaultControlMatch);
|
|
4534
|
+
}
|
|
4535
|
+
|
|
4536
|
+
_isEmptyFilterValue(v) {
|
|
4537
|
+
if (v == null || v === '' || v === false) return true;
|
|
4538
|
+
if (Array.isArray(v)) return v.length === 0;
|
|
4539
|
+
if (typeof v === 'object' && !(v instanceof Date)) {
|
|
4540
|
+
return Object.values(v).every((x) => x == null || x === '');
|
|
4541
|
+
}
|
|
4542
|
+
return false;
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
_destroyOneControl(control) {
|
|
4546
|
+
try { control?.destroy?.(); } catch (err) { console.error('[DataTable] filter control destroy threw', err); }
|
|
4547
|
+
control?.el?.remove?.();
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
_destroyFilterControls() {
|
|
4551
|
+
for (const { control } of this._filterControls.values()) this._destroyOneControl(control);
|
|
4552
|
+
this._filterControls.clear();
|
|
4553
|
+
}
|
|
4554
|
+
|
|
4555
|
+
_notifyFilterChange() {
|
|
4556
|
+
if (typeof this.config.onFilterChange !== 'function') return;
|
|
4557
|
+
try { this.config.onFilterChange(this._filterList()); } catch (err) {
|
|
4558
|
+
console.error('[DataTable] onFilterChange threw', err);
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
|
|
4562
|
+
/** The column filters in force: `[{column, header, value}]`. */
|
|
4563
|
+
getFilters() {
|
|
4564
|
+
return this._filterList();
|
|
4565
|
+
}
|
|
4566
|
+
|
|
4567
|
+
/**
|
|
4568
|
+
* Set one column's filter (`null`, `''` or an empty array clears it). The
|
|
4569
|
+
* value is whatever the column's control works in — text for the 0.4 box.
|
|
4570
|
+
*/
|
|
4571
|
+
setFilter(colIdx, value) {
|
|
4572
|
+
const textBox = !this._filterKindFor(colIdx, (this.config.headers || [])[colIdx]);
|
|
4573
|
+
const v = textBox && value != null && typeof value !== 'string' ? String(value) : value;
|
|
4574
|
+
if (textBox) {
|
|
4575
|
+
const filterRow = this._wrapperEl?.querySelector('.twm-data-table__filter-row');
|
|
4576
|
+
const cell = filterRow?.children[this.config.showRowNumbers ? colIdx + 1 : colIdx];
|
|
4577
|
+
const input = cell?.querySelector('.twm-data-table__filter-input');
|
|
4578
|
+
if (input) input.value = v || '';
|
|
4579
|
+
}
|
|
4580
|
+
this._onFilterInput(colIdx, v ?? '');
|
|
4581
|
+
}
|
|
4582
|
+
|
|
4583
|
+
/**
|
|
4584
|
+
* Counts for a column's filter control — chips draw them beside each
|
|
4585
|
+
* option (keyed by the option's value; `''` is "no filter"), a dimmed
|
|
4586
|
+
* chip at zero. `setFilterCounts(colIdx, counts)`, or
|
|
4587
|
+
* `setFilterCounts({[colIdx]: counts})` for several. They are kept, so a
|
|
4588
|
+
* control built later starts with them.
|
|
4589
|
+
*/
|
|
4590
|
+
setFilterCounts(colIdx, counts) {
|
|
4591
|
+
if (colIdx && typeof colIdx === 'object' && counts === undefined) {
|
|
4592
|
+
for (const [k, c] of Object.entries(colIdx)) this.setFilterCounts(Number(k), c);
|
|
4593
|
+
return;
|
|
4594
|
+
}
|
|
4595
|
+
const col = Number(colIdx);
|
|
4596
|
+
if (!Number.isInteger(col)) return;
|
|
4597
|
+
if (counts == null) this._filterCounts.delete(col); else this._filterCounts.set(col, counts);
|
|
4598
|
+
const entry = this._filterControls.get(col);
|
|
4599
|
+
if (entry) {
|
|
4600
|
+
try { entry.control.setCounts?.(counts ?? null); } catch (err) {
|
|
4601
|
+
console.error('[DataTable] filter control setCounts threw', err);
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
|
|
4606
|
+
/** What `setFilterCounts` last gave a column, or null. */
|
|
4607
|
+
getFilterCounts(colIdx) {
|
|
4608
|
+
return this._filterCounts.get(Number(colIdx)) ?? null;
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
// ── focus across a render ──────────────────────────────────────────
|
|
4612
|
+
|
|
4613
|
+
_captureFocus070(doc) {
|
|
4614
|
+
const active = doc?.activeElement;
|
|
4615
|
+
if (!active || active === doc.body) return null;
|
|
4616
|
+
for (const [colIdx, { control }] of this._filterControls) {
|
|
4617
|
+
if (control.el?.contains?.(active)) {
|
|
4618
|
+
let sel = null;
|
|
4619
|
+
try {
|
|
4620
|
+
if (typeof active.selectionStart === 'number') sel = [active.selectionStart, active.selectionEnd];
|
|
4621
|
+
} catch (_) { /* not a text field */ }
|
|
4622
|
+
return { kind: 'control', colIdx, el: active, sel };
|
|
4623
|
+
}
|
|
4624
|
+
}
|
|
4625
|
+
// Inside an open detail (a button in it, a field): the detail is drawn
|
|
4626
|
+
// again by the render, so the same place in it — by its path under the
|
|
4627
|
+
// detail's host — gets the focus back (the release review).
|
|
4628
|
+
const detailHost = active.closest?.('.twm-dt-detail');
|
|
4629
|
+
if (detailHost && this._tbodyEl?.contains(detailHost)) {
|
|
4630
|
+
const tr = detailHost.closest('tr')?.__detailOf;
|
|
4631
|
+
if (tr && tr.__rowIndex !== undefined) {
|
|
4632
|
+
const path = [];
|
|
4633
|
+
for (let n = active; n && n !== detailHost; n = n.parentElement) {
|
|
4634
|
+
path.unshift(Array.prototype.indexOf.call(n.parentElement.children, n));
|
|
4635
|
+
}
|
|
4636
|
+
let sel = null;
|
|
4637
|
+
try {
|
|
4638
|
+
if (typeof active.selectionStart === 'number') sel = [active.selectionStart, active.selectionEnd];
|
|
4639
|
+
} catch (_) { /* not a text field */ }
|
|
4640
|
+
return { kind: 'detail', key: this._rowKey(tr.__row, tr.__rowIndex), path, tag: active.tagName, sel };
|
|
4641
|
+
}
|
|
4642
|
+
}
|
|
4643
|
+
const toggle = active.closest?.('.twm-dt-detail-toggle');
|
|
4644
|
+
if (toggle && this._tbodyEl?.contains(toggle)) {
|
|
4645
|
+
const tr = toggle.closest('tr');
|
|
4646
|
+
if (tr && tr.__rowIndex !== undefined) {
|
|
4647
|
+
return { kind: 'toggle', key: this._rowKey(tr.__row, tr.__rowIndex) };
|
|
4648
|
+
}
|
|
4649
|
+
}
|
|
4650
|
+
return null;
|
|
4651
|
+
}
|
|
4652
|
+
|
|
4653
|
+
_restoreFocus070(f) {
|
|
4654
|
+
try {
|
|
4655
|
+
if (f.kind === 'control') {
|
|
4656
|
+
if (!f.el.isConnected) return;
|
|
4657
|
+
f.el.focus({ preventScroll: true });
|
|
4658
|
+
if (f.sel) f.el.setSelectionRange?.(f.sel[0], f.sel[1]);
|
|
4659
|
+
} else if (f.kind === 'detail' && f.key !== null) {
|
|
4660
|
+
let n = this._rowElByKey(f.key)?.__detail?.[0]?.querySelector('.twm-dt-detail');
|
|
4661
|
+
for (const i of f.path) n = n?.children[i];
|
|
4662
|
+
if (n && n.tagName === f.tag) {
|
|
4663
|
+
n.focus({ preventScroll: true });
|
|
4664
|
+
if (f.sel) n.setSelectionRange?.(f.sel[0], f.sel[1]);
|
|
4665
|
+
}
|
|
4666
|
+
} else if (f.kind === 'toggle' && f.key !== null) {
|
|
4667
|
+
const tr = this._rowElByKey(f.key);
|
|
4668
|
+
tr?.querySelector(':scope > td > .twm-dt-detail-toggle')?.focus({ preventScroll: true });
|
|
4669
|
+
}
|
|
4670
|
+
} catch (_) { /* detached */ }
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
/** The one row spanning every column that says there are no rows (or why
|
|
4674
|
+
* they could not be read). Under `dataSource` the plain message is drawn
|
|
4675
|
+
* by class, so it is quiet while the first answer is on its way. */
|
|
4676
|
+
_buildEmptyRow(colCount, emptyMessage) {
|
|
4677
|
+
const emptyTr = document.createElement('tr');
|
|
4678
|
+
emptyTr.className = 'data-preview-row data-table__empty-row';
|
|
4679
|
+
const emptyTd = document.createElement('td');
|
|
4680
|
+
emptyTd.colSpan = colCount;
|
|
4681
|
+
if (this._error) {
|
|
4682
|
+
// 0.5.0. Nothing to keep: the failure takes the empty row.
|
|
4683
|
+
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--error';
|
|
4684
|
+
emptyTd.setAttribute('role', 'alert');
|
|
4685
|
+
this._appendStateContent(emptyTd, this._errorContent());
|
|
4686
|
+
} else if (this.config.emptyState !== undefined) {
|
|
4687
|
+
// 0.5.0. The consumer's own words (or node), styled by class
|
|
4688
|
+
// rather than inline, and quiet while the table is loading.
|
|
4689
|
+
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
|
|
4690
|
+
const state = typeof this.config.emptyState === 'function'
|
|
4691
|
+
? this.config.emptyState() : this.config.emptyState;
|
|
4692
|
+
this._appendStateContent(emptyTd, state);
|
|
4693
|
+
} else if (this.config.dataSource) {
|
|
4694
|
+
emptyTd.className = 'twm-data-table__empty-cell twm-data-table__empty-cell--state';
|
|
4695
|
+
emptyTd.textContent = emptyMessage;
|
|
4696
|
+
} else {
|
|
4697
|
+
emptyTd.style.cssText
|
|
4698
|
+
= 'text-align:center; font-style:italic; color:#888; padding:16px;';
|
|
4699
|
+
emptyTd.textContent = emptyMessage;
|
|
4700
|
+
}
|
|
4701
|
+
emptyTr.appendChild(emptyTd);
|
|
4702
|
+
return emptyTr;
|
|
4703
|
+
}
|
|
4704
|
+
|
|
3183
4705
|
// ─────────────────────────────────────────────────────────────────
|
|
3184
4706
|
// 0.5.0 — decoration, states and the typed columns
|
|
3185
4707
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -3412,6 +4934,10 @@ export class DataTable {
|
|
|
3412
4934
|
tr.__rowKey = this._rowKey(row, rowIdx);
|
|
3413
4935
|
}
|
|
3414
4936
|
this._paintActive(tr);
|
|
4937
|
+
// 0.7.0 `follow`/`refresh`: a row the last answer did not have.
|
|
4938
|
+
if (this._newKeys) {
|
|
4939
|
+
tr.classList.toggle('twm-dt-row--new', this._newKeys.has(this._rowKey(row, rowIdx)));
|
|
4940
|
+
}
|
|
3415
4941
|
}
|
|
3416
4942
|
|
|
3417
4943
|
/** The open mark on one row, from `_activeKey`. */
|
|
@@ -3616,6 +5142,30 @@ export class DataTable {
|
|
|
3616
5142
|
: 0;
|
|
3617
5143
|
natural[i] = Math.max(body, head);
|
|
3618
5144
|
}
|
|
5145
|
+
// 0.7.0 `filterKinds`: A FILTER CONTROL IS IN THE MEASURE. The text
|
|
5146
|
+
// boxes stay out (an `<input>`'s default width would set every
|
|
5147
|
+
// column), but a control — the Level chips with their counts — is
|
|
5148
|
+
// drawn at its NARROWEST (`min-content`: the widest chip) for this
|
|
5149
|
+
// reflow, so the label row's width above already spans it, and that
|
|
5150
|
+
// narrowest width is kept as the column's FLOOR for the fit pass,
|
|
5151
|
+
// which would otherwise squeeze the column under one chip in a narrow
|
|
5152
|
+
// tile and cut it off. Above the floor the cell wraps its chips.
|
|
5153
|
+
const floors = new Array(cols).fill(0);
|
|
5154
|
+
const filterRow = headerTable.querySelector('thead > tr.twm-data-table__filter-row--controls');
|
|
5155
|
+
if (filterRow) {
|
|
5156
|
+
for (let i = 0; i < cols && i < filterRow.children.length; i++) {
|
|
5157
|
+
const th = filterRow.children[i];
|
|
5158
|
+
if (!th.classList.contains('data-table__filter-cell--control')) continue;
|
|
5159
|
+
const el = th.firstElementChild;
|
|
5160
|
+
if (!el) continue;
|
|
5161
|
+
const cs = getComputedStyle(th);
|
|
5162
|
+
const pad = (parseFloat(cs.paddingLeft) || 0) + (parseFloat(cs.paddingRight) || 0)
|
|
5163
|
+
+ (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.borderRightWidth) || 0);
|
|
5164
|
+
floors[i] = Math.ceil(el.getBoundingClientRect().width / zoom + pad);
|
|
5165
|
+
natural[i] = Math.max(natural[i], floors[i]);
|
|
5166
|
+
}
|
|
5167
|
+
}
|
|
5168
|
+
this._filterFloors = floors;
|
|
3619
5169
|
|
|
3620
5170
|
for (const tr of sampled) tr.remove();
|
|
3621
5171
|
headerTable.classList.remove('twm-dt-measuring');
|
|
@@ -3804,6 +5354,7 @@ export class DataTable {
|
|
|
3804
5354
|
firstIdx,
|
|
3805
5355
|
overrides: this._colWidths,
|
|
3806
5356
|
fit: this.config.columnFit,
|
|
5357
|
+
floors: this._filterFloors,
|
|
3807
5358
|
});
|
|
3808
5359
|
const total = widths.reduce((a, b) => a + b, 0);
|
|
3809
5360
|
|
|
@@ -3913,6 +5464,11 @@ export class DataTable {
|
|
|
3913
5464
|
const n = natural.length;
|
|
3914
5465
|
const firstIdx = opts.firstIdx ?? 0;
|
|
3915
5466
|
const overrides = opts.overrides || {};
|
|
5467
|
+
// 0.7.0: a column's own floor (its filter control's narrowest width,
|
|
5468
|
+
// `_measureNaturalWidths`) — never squeezed below it, even when the
|
|
5469
|
+
// table then scrolls sideways.
|
|
5470
|
+
const floors = Array.isArray(opts.floors) ? opts.floors : [];
|
|
5471
|
+
const floorOf = (i) => Math.max(FLOOR, Math.ceil(floors[i] || 0));
|
|
3916
5472
|
// Anything that is not the literal opt-in is the historical behaviour.
|
|
3917
5473
|
// Read once, so an embedder that passes a typo gets the old layout
|
|
3918
5474
|
// rather than half of each.
|
|
@@ -3993,7 +5549,7 @@ export class DataTable {
|
|
|
3993
5549
|
const budget = avail - protectedSum;
|
|
3994
5550
|
if (budget < shrinkable.length * FLOOR) {
|
|
3995
5551
|
// Even at the floor we overflow → let the body scroll sideways.
|
|
3996
|
-
for (const i of shrinkable) widths[i] =
|
|
5552
|
+
for (const i of shrinkable) widths[i] = floorOf(i);
|
|
3997
5553
|
return widths;
|
|
3998
5554
|
}
|
|
3999
5555
|
// Max-min fair allocation: the narrow columns keep their content,
|
|
@@ -4010,7 +5566,7 @@ export class DataTable {
|
|
|
4010
5566
|
} else {
|
|
4011
5567
|
const level = Math.max(FLOOR, fair);
|
|
4012
5568
|
for (let j = k; j < shrinkable.length; j++) {
|
|
4013
|
-
widths[shrinkable[j]] = level;
|
|
5569
|
+
widths[shrinkable[j]] = Math.max(level, floorOf(shrinkable[j]));
|
|
4014
5570
|
}
|
|
4015
5571
|
// Dump any rounding remainder onto the widest column.
|
|
4016
5572
|
const leftover = remaining - colsLeft * level;
|
|
@@ -4296,6 +5852,9 @@ export class DataTable {
|
|
|
4296
5852
|
: bodyTable.querySelectorAll('tbody td');
|
|
4297
5853
|
cells.forEach((td) => {
|
|
4298
5854
|
if (td.parentElement?.classList.contains('twm-dt-spacer')) return;
|
|
5855
|
+
// 0.7.0: a row's detail — and anything drawn inside it — is not a
|
|
5856
|
+
// clipped cell of the table.
|
|
5857
|
+
if (td.closest?.('.twm-dt-detail-row')) return;
|
|
4299
5858
|
const clipped = td.scrollWidth > td.clientWidth + 1;
|
|
4300
5859
|
if (clipped) {
|
|
4301
5860
|
if (!td.title) td.title = td.textContent;
|
|
@@ -4360,6 +5919,25 @@ export class DataTable {
|
|
|
4360
5919
|
this._installActivation(tbody, table);
|
|
4361
5920
|
}
|
|
4362
5921
|
|
|
5922
|
+
// 0.7.0 `rowDetail` from the keyboard: on the focused table (or a row's
|
|
5923
|
+
// toggle), ArrowRight opens the current row's detail and ArrowLeft
|
|
5924
|
+
// closes it — the tree-grid keys. The toggle itself is a button, so
|
|
5925
|
+
// Enter and Space work on it as on any button.
|
|
5926
|
+
if (this._detailOn()) {
|
|
5927
|
+
const onDetailKey = (ev) => {
|
|
5928
|
+
if (ev.key !== 'ArrowRight' && ev.key !== 'ArrowLeft') return;
|
|
5929
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) return;
|
|
5930
|
+
const toggle = ev.target?.closest?.('.twm-dt-detail-toggle');
|
|
5931
|
+
let tr = null;
|
|
5932
|
+
if (toggle && table.contains(toggle)) tr = toggle.closest('tr');
|
|
5933
|
+
else if (ev.target === table) tr = this._currentRowEl();
|
|
5934
|
+
if (!tr || tr.__rowIndex === undefined) return;
|
|
5935
|
+
if (this._setRowElExpanded(tr, ev.key === 'ArrowRight', ev)) ev.preventDefault();
|
|
5936
|
+
};
|
|
5937
|
+
table.addEventListener('keydown', onDetailKey);
|
|
5938
|
+
this._disposers.push(() => table.removeEventListener('keydown', onDetailKey));
|
|
5939
|
+
}
|
|
5940
|
+
|
|
4363
5941
|
if (copyable) {
|
|
4364
5942
|
const handleContextMenu = (event) => {
|
|
4365
5943
|
const rowEl = event.target.closest('tr');
|
|
@@ -4367,6 +5945,12 @@ export class DataTable {
|
|
|
4367
5945
|
this._hideContextMenu();
|
|
4368
5946
|
return;
|
|
4369
5947
|
}
|
|
5948
|
+
// 0.7.0: inside a row's detail, the page's own menu (copy the
|
|
5949
|
+
// text a person selected there), not the table's.
|
|
5950
|
+
if (rowEl.closest('.twm-dt-detail-row')) {
|
|
5951
|
+
this._hideContextMenu();
|
|
5952
|
+
return;
|
|
5953
|
+
}
|
|
4370
5954
|
|
|
4371
5955
|
event.preventDefault();
|
|
4372
5956
|
event.stopPropagation();
|