flexdesk 0.6.2 → 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-CPYBQN3Q.js → chunk-JLCJWCRS.js} +1617 -58
- package/dist/chunk-JLCJWCRS.js.map +7 -0
- package/dist/{chunk-XYUZF5CD.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 +1695 -70
- 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-CPYBQN3Q.js.map +0 -7
- /package/dist/{chunk-XYUZF5CD.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;
|
|
@@ -1088,7 +1256,12 @@ export class DataTable {
|
|
|
1088
1256
|
// body's auto layout, then we lock both tables to those widths
|
|
1089
1257
|
// via table-layout:fixed + explicit cell widths.
|
|
1090
1258
|
if (this._headerTableEl && this._tableEl) {
|
|
1091
|
-
|
|
1259
|
+
// 0.6.3: and once the widths are fixed the rows may be another
|
|
1260
|
+
// height than they were measured at — so the window measures again.
|
|
1261
|
+
requestAnimationFrame(() => {
|
|
1262
|
+
this._syncHeaderWidths();
|
|
1263
|
+
this._virtRemeasure();
|
|
1264
|
+
});
|
|
1092
1265
|
// Re-sync on container resize so columns stay aligned when
|
|
1093
1266
|
// the parent flex / grid layout changes (window resize,
|
|
1094
1267
|
// splitter drag, etc.).
|
|
@@ -1114,7 +1287,8 @@ export class DataTable {
|
|
|
1114
1287
|
|
|
1115
1288
|
// Install interactions (readonly only prevents editing, not selection/copy)
|
|
1116
1289
|
if (rows.length > 0 && (this.config.selectable || this.config.copyable || !this.config.readonly
|
|
1117
|
-
|| typeof this.config.onRowActivate === 'function'
|
|
1290
|
+
|| typeof this.config.onRowActivate === 'function'
|
|
1291
|
+
|| typeof this.config.rowDetail === 'function')) {
|
|
1118
1292
|
this._installInteractions();
|
|
1119
1293
|
}
|
|
1120
1294
|
|
|
@@ -1125,6 +1299,25 @@ export class DataTable {
|
|
|
1125
1299
|
else if (hadTableFocus && this._tableEl) {
|
|
1126
1300
|
try { this._tableEl.focus({ preventScroll: true }); } catch (_) { /* detached */ }
|
|
1127
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
|
+
}
|
|
1128
1321
|
}
|
|
1129
1322
|
|
|
1130
1323
|
/**
|
|
@@ -1252,6 +1445,19 @@ export class DataTable {
|
|
|
1252
1445
|
_teardown(emptyHost) {
|
|
1253
1446
|
autoDisposeUnwatch(this);
|
|
1254
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();
|
|
1255
1461
|
this._runOffs(this._activationOffs);
|
|
1256
1462
|
this._activationOffs = [];
|
|
1257
1463
|
this._cleanup();
|
|
@@ -1290,7 +1496,7 @@ export class DataTable {
|
|
|
1290
1496
|
_getProcessedRows() {
|
|
1291
1497
|
if (this._processedRows !== null) return this._processedRows;
|
|
1292
1498
|
|
|
1293
|
-
const isServerSide =
|
|
1499
|
+
const isServerSide = this._isServerSide();
|
|
1294
1500
|
let rows = this.config.rows;
|
|
1295
1501
|
let indexMap = rows.map((_, i) => i);
|
|
1296
1502
|
|
|
@@ -1317,6 +1523,9 @@ export class DataTable {
|
|
|
1317
1523
|
const filters = this._state.filters;
|
|
1318
1524
|
const filteredRows = [];
|
|
1319
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));
|
|
1320
1529
|
|
|
1321
1530
|
for (let i = 0; i < rows.length; i++) {
|
|
1322
1531
|
const row = rows[i];
|
|
@@ -1325,6 +1534,14 @@ export class DataTable {
|
|
|
1325
1534
|
for (const [colIdx, filterText] of filters) {
|
|
1326
1535
|
if (!filterText) continue;
|
|
1327
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
|
+
}
|
|
1328
1545
|
const colType = this._valueKind(colIdx);
|
|
1329
1546
|
|
|
1330
1547
|
if (colType === 'num') {
|
|
@@ -1515,6 +1732,20 @@ export class DataTable {
|
|
|
1515
1732
|
const th = document.createElement('th');
|
|
1516
1733
|
th.className = 'data-table__filter-cell';
|
|
1517
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
|
+
|
|
1518
1749
|
const wrapper = document.createElement('div');
|
|
1519
1750
|
wrapper.className = 'twm-data-table__filter-wrapper';
|
|
1520
1751
|
|
|
@@ -1863,17 +2094,25 @@ export class DataTable {
|
|
|
1863
2094
|
if (this._filterDebounceTimer) {
|
|
1864
2095
|
clearTimeout(this._filterDebounceTimer);
|
|
1865
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 };
|
|
1866
2100
|
this._filterDebounceTimer = setTimeout(() => {
|
|
2101
|
+
this._pendingFilter = null;
|
|
1867
2102
|
this._onFilterInput(colIdx, value);
|
|
1868
2103
|
}, 200);
|
|
1869
2104
|
}
|
|
1870
2105
|
|
|
1871
2106
|
_onFilterInput(colIdx, value) {
|
|
1872
|
-
|
|
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) {
|
|
1873
2111
|
this._state.filters.set(colIdx, value);
|
|
1874
2112
|
} else {
|
|
1875
2113
|
this._state.filters.delete(colIdx);
|
|
1876
2114
|
}
|
|
2115
|
+
this._pendingFilter = null;
|
|
1877
2116
|
|
|
1878
2117
|
this._invalidateProcessedCache();
|
|
1879
2118
|
this._state.offset = 0;
|
|
@@ -1881,9 +2120,40 @@ export class DataTable {
|
|
|
1881
2120
|
this._state.anchorIndex = null;
|
|
1882
2121
|
|
|
1883
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
|
+
}
|
|
1884
2138
|
this.render();
|
|
1885
2139
|
}
|
|
1886
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
|
+
|
|
1887
2157
|
_getActiveFilterColIdx() {
|
|
1888
2158
|
const active = document.activeElement;
|
|
1889
2159
|
if (!active || !active.classList.contains('twm-data-table__filter-input')) return null;
|
|
@@ -1987,6 +2257,10 @@ export class DataTable {
|
|
|
1987
2257
|
}
|
|
1988
2258
|
|
|
1989
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();
|
|
1990
2264
|
const { rows, pageSize, totalCount: configTotalCount, offset: configOffset, onPageChange, onJumpToRow } = this.config;
|
|
1991
2265
|
|
|
1992
2266
|
// Server-side pagination uses config offset, client-side uses state offset
|
|
@@ -2147,6 +2421,33 @@ export class DataTable {
|
|
|
2147
2421
|
if (!p || typeof p !== 'object') return null;
|
|
2148
2422
|
const size = Number.isFinite(p.pageSize) && p.pageSize >= 1
|
|
2149
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
|
+
}
|
|
2150
2451
|
return {
|
|
2151
2452
|
...p,
|
|
2152
2453
|
pageSize: size,
|
|
@@ -2181,7 +2482,15 @@ export class DataTable {
|
|
|
2181
2482
|
*/
|
|
2182
2483
|
setPaging(patch = {}) {
|
|
2183
2484
|
if (!this.config.paging) return;
|
|
2485
|
+
const before = this.config.dataSource ? this.pagingState?.mode : null;
|
|
2184
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
|
+
}
|
|
2185
2494
|
this._refreshPagingStrip();
|
|
2186
2495
|
this._pagingMaybeLoadMore();
|
|
2187
2496
|
}
|
|
@@ -2237,6 +2546,9 @@ export class DataTable {
|
|
|
2237
2546
|
_pagingMaybeLoadMore() {
|
|
2238
2547
|
const p = this._pagingConfig();
|
|
2239
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;
|
|
2240
2552
|
const state = this.pagingState;
|
|
2241
2553
|
if (state.mode !== 'scroll' || p.held >= state.hardLimit) return;
|
|
2242
2554
|
const wrap = this._tableWrapEl;
|
|
@@ -2279,10 +2591,10 @@ export class DataTable {
|
|
|
2279
2591
|
else if (known > to) of = ` of more than ${n(known - 1)}`;
|
|
2280
2592
|
info.textContent = held ? `Rows ${n(from)}–${n(to)}${of}` : 'No rows on this page';
|
|
2281
2593
|
|
|
2282
|
-
|
|
2283
|
-
const
|
|
2284
|
-
const
|
|
2285
|
-
|
|
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;
|
|
2286
2598
|
// A disabled button's click is ignored here too: a browser sends none,
|
|
2287
2599
|
// but a synthetic one (a test, a script) still reaches the listener.
|
|
2288
2600
|
const go = (offset) => (ev) => {
|
|
@@ -2291,26 +2603,36 @@ export class DataTable {
|
|
|
2291
2603
|
const nav = document.createElement('span');
|
|
2292
2604
|
nav.className = 'twm-dt-paging__nav';
|
|
2293
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));
|
|
2294
2614
|
nav.append(
|
|
2295
|
-
|
|
2296
|
-
|
|
2615
|
+
button('first_page', 'first', 'First page', off || !hasPrev, go(0)),
|
|
2616
|
+
button('chevron_left', 'prev', 'Previous page', off || !hasPrev,
|
|
2297
2617
|
go(Math.max(0, p.offset - pageSize))),
|
|
2298
2618
|
);
|
|
2299
2619
|
const page = document.createElement('span');
|
|
2300
2620
|
page.className = 'twm-dt-paging__page';
|
|
2301
|
-
const current = Math.floor(p.offset / pageSize) + 1;
|
|
2302
2621
|
page.textContent = exact
|
|
2303
|
-
? `Page ${n(
|
|
2304
|
-
: `Page ${n(
|
|
2622
|
+
? `Page ${n(pn.page)} of ${n(pn.pages)}`
|
|
2623
|
+
: `Page ${n(pn.page)}`;
|
|
2305
2624
|
nav.append(
|
|
2306
2625
|
page,
|
|
2307
|
-
|
|
2626
|
+
button('chevron_right', 'next', 'Next page', off || !hasNext,
|
|
2308
2627
|
go(p.offset + pageSize)),
|
|
2309
2628
|
// The last page needs the exact count: without it there is no
|
|
2310
2629
|
// offset to go to, and the button says so by being off.
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
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)),
|
|
2314
2636
|
);
|
|
2315
2637
|
el.appendChild(nav);
|
|
2316
2638
|
} else {
|
|
@@ -2372,12 +2694,17 @@ export class DataTable {
|
|
|
2372
2694
|
}
|
|
2373
2695
|
|
|
2374
2696
|
_createTable() {
|
|
2375
|
-
const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon
|
|
2697
|
+
const { headers, rows, pageSize, sortable, filterable, showRowNumbers, readonly, getHeaderIcon } = this.config;
|
|
2376
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;
|
|
2377
2704
|
|
|
2378
2705
|
// Server-side pagination: rows already represent current page, use config offset for row numbering
|
|
2379
2706
|
// Client-side pagination: use processed (filtered+sorted) rows, then slice for current page
|
|
2380
|
-
const isServerSide =
|
|
2707
|
+
const isServerSide = this._isServerSide();
|
|
2381
2708
|
const processedRows = isServerSide ? rows : this._getProcessedRows();
|
|
2382
2709
|
const offset = isServerSide ? (configOffset || 0) : this._state.offset;
|
|
2383
2710
|
// NO PAGINATION MEANS NO PAGES. This used to slice to `pageSize` either
|
|
@@ -2482,11 +2809,13 @@ export class DataTable {
|
|
|
2482
2809
|
|
|
2483
2810
|
// 0.5.0 `showRowNumbers: 'position'`: where the first row drawn here
|
|
2484
2811
|
// stands in the order SHOWN (the page's first row on a later page).
|
|
2485
|
-
this._positionBase =
|
|
2812
|
+
this._positionBase = this.config.dataSource ? (this._source?.offset || 0)
|
|
2813
|
+
: isServerSide ? (configOffset || 0)
|
|
2486
2814
|
: (this.config.pagination ? offset : 0);
|
|
2487
2815
|
// `paging`: a page's rows stand at its offset in the SOURCE, which only
|
|
2488
|
-
// the numbering reads — their indexes stay those of `rows`.
|
|
2489
|
-
|
|
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();
|
|
2490
2819
|
if (paged && Number.isFinite(paged.offset)) this._positionBase += paged.offset;
|
|
2491
2820
|
|
|
2492
2821
|
// Body
|
|
@@ -2500,7 +2829,10 @@ export class DataTable {
|
|
|
2500
2829
|
});
|
|
2501
2830
|
} else {
|
|
2502
2831
|
pageRows.forEach((row, localIdx) => {
|
|
2503
|
-
|
|
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);
|
|
2504
2836
|
});
|
|
2505
2837
|
}
|
|
2506
2838
|
|
|
@@ -2664,6 +2996,9 @@ export class DataTable {
|
|
|
2664
2996
|
const offsets = new Float64Array(n + 1);
|
|
2665
2997
|
const fn = typeof this.config.virtualRowHeight === 'function'
|
|
2666
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;
|
|
2667
3002
|
let y = 0;
|
|
2668
3003
|
for (let i = 0; i < n; i++) {
|
|
2669
3004
|
let h = v.base;
|
|
@@ -2671,6 +3006,7 @@ export class DataTable {
|
|
|
2671
3006
|
const asked = fn(v.globalOf(i), v.base);
|
|
2672
3007
|
h = Number.isFinite(asked) && asked > 0 ? asked : 0;
|
|
2673
3008
|
}
|
|
3009
|
+
if (details && h > 0) h += this._detailHeightOf(v.rows[i], v.globalOf(i), v.base);
|
|
2674
3010
|
heights[i] = h;
|
|
2675
3011
|
offsets[i] = y;
|
|
2676
3012
|
y += h;
|
|
@@ -2695,26 +3031,67 @@ export class DataTable {
|
|
|
2695
3031
|
return lo;
|
|
2696
3032
|
}
|
|
2697
3033
|
|
|
2698
|
-
/**
|
|
2699
|
-
*
|
|
2700
|
-
*
|
|
3034
|
+
/**
|
|
3035
|
+
* Measure the rows that are DRAWN — the median of up to 64 of them. Client
|
|
3036
|
+
* pixels, so the zoom is divided back out (`_measureNaturalWidths` explains
|
|
3037
|
+
* why), and NOT `offsetHeight`, which is rounded: 21 for a 21.19px row is a
|
|
3038
|
+
* 1,100px error over 6,000 rows.
|
|
3039
|
+
*
|
|
3040
|
+
* 0.6.3: IT USED TO MEASURE ONE ROW, ONCE, AT RENDER, and trust it for the
|
|
3041
|
+
* life of the table. A row measured at a bad moment — a box not yet laid
|
|
3042
|
+
* out at its width, a font not yet loaded — set every row's height: a
|
|
3043
|
+
* Tables grid drawing 22.8px rows was found holding a 134px estimate, so the
|
|
3044
|
+
* window took a 650px viewport for five rows, drew rows 1–35 and left the
|
|
3045
|
+
* rest of the box blank spacer while the reader scrolled. Now it is asked
|
|
3046
|
+
* again on every window update (`_virtUpdate`), on a resize and once the
|
|
3047
|
+
* column widths are fixed, and corrects itself the first time real rows
|
|
3048
|
+
* are on screen. The first measure is exact; after that a change has to be
|
|
3049
|
+
* larger than half a pixel, so rows of mixed heights cannot make the
|
|
3050
|
+
* offsets move under the reader on every scroll.
|
|
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
|
+
*
|
|
3060
|
+
* @returns {boolean} whether the heights changed (the offsets are recomputed).
|
|
3061
|
+
*/
|
|
2701
3062
|
_virtMeasure() {
|
|
2702
3063
|
const v = this._virt;
|
|
2703
3064
|
if (!v) return false;
|
|
3065
|
+
const zoom = this._tableEl?.currentCSSZoom || 1;
|
|
3066
|
+
const heights = [];
|
|
2704
3067
|
for (const tr of v.map.values()) {
|
|
2705
3068
|
if (!tr.isConnected || tr.hidden) continue;
|
|
2706
|
-
const zoom = this._tableEl?.currentCSSZoom || 1;
|
|
2707
3069
|
const h = tr.getBoundingClientRect().height / zoom;
|
|
2708
|
-
if (
|
|
2709
|
-
if (
|
|
3070
|
+
if (h > 0) heights.push(h);
|
|
3071
|
+
if (heights.length >= 64) break;
|
|
3072
|
+
}
|
|
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) {
|
|
2710
3082
|
v.base = h;
|
|
2711
3083
|
this._virtBase = h;
|
|
2712
|
-
|
|
2713
|
-
return true;
|
|
3084
|
+
base = true;
|
|
2714
3085
|
}
|
|
2715
|
-
return false;
|
|
2716
3086
|
}
|
|
2717
|
-
return false;
|
|
3087
|
+
if (!base && !details) return false;
|
|
3088
|
+
this._virtComputeOffsets();
|
|
3089
|
+
return true;
|
|
3090
|
+
}
|
|
3091
|
+
|
|
3092
|
+
/** Measure again, and redraw the window when the heights moved. */
|
|
3093
|
+
_virtRemeasure() {
|
|
3094
|
+
if (this._virtMeasure()) this._virtUpdate({ force: true });
|
|
2718
3095
|
}
|
|
2719
3096
|
|
|
2720
3097
|
/**
|
|
@@ -2734,9 +3111,12 @@ export class DataTable {
|
|
|
2734
3111
|
* @param {number} [o.viewport] the viewport height, likewise.
|
|
2735
3112
|
* @returns {HTMLElement[]} the rows added.
|
|
2736
3113
|
*/
|
|
2737
|
-
_virtUpdate({ force = false, notify = true, top = null, viewport = null } = {}) {
|
|
3114
|
+
_virtUpdate({ force = false, notify = true, top = null, viewport = null, measure = true } = {}) {
|
|
2738
3115
|
const v = this._virt;
|
|
2739
3116
|
if (!v || !v.offsets) return [];
|
|
3117
|
+
// The rows on screen say how tall a row is — before the window is
|
|
3118
|
+
// placed, so a stale estimate is not used to place it (`_virtMeasure`).
|
|
3119
|
+
if (measure && this._virtMeasure()) force = true;
|
|
2740
3120
|
const n = v.rows.length;
|
|
2741
3121
|
const wrap = this._tableWrapEl;
|
|
2742
3122
|
const view = viewport || wrap?.clientHeight || VIRTUAL_FALLBACK_VIEWPORT_PX;
|
|
@@ -2764,6 +3144,13 @@ export class DataTable {
|
|
|
2764
3144
|
console.error('[DataTable] onRowsRendered threw', err);
|
|
2765
3145
|
}
|
|
2766
3146
|
}
|
|
3147
|
+
// And the rows just drawn: if they disagree with the estimate the
|
|
3148
|
+
// window was placed by, place it once more by what they say.
|
|
3149
|
+
if (measure && added.length && this._virtMeasure()) {
|
|
3150
|
+
return added.concat(this._virtUpdate({
|
|
3151
|
+
force: true, notify, top, viewport, measure: false,
|
|
3152
|
+
}));
|
|
3153
|
+
}
|
|
2767
3154
|
}
|
|
2768
3155
|
return added;
|
|
2769
3156
|
}
|
|
@@ -2777,7 +3164,12 @@ export class DataTable {
|
|
|
2777
3164
|
for (let i = start; i < end; i++) if (v.heights[i] > 0) want.push(i);
|
|
2778
3165
|
const wanted = new Set(want);
|
|
2779
3166
|
for (const [i, tr] of v.map) {
|
|
2780
|
-
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
|
+
}
|
|
2781
3173
|
}
|
|
2782
3174
|
v.start = start;
|
|
2783
3175
|
v.end = end;
|
|
@@ -2794,10 +3186,14 @@ export class DataTable {
|
|
|
2794
3186
|
const tr = this._buildBodyRow(v.rows[i], v.globalOf(i), i);
|
|
2795
3187
|
v.map.set(i, tr);
|
|
2796
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);
|
|
2797
3191
|
ref = tr;
|
|
2798
3192
|
added.push(tr);
|
|
2799
3193
|
}
|
|
2800
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);
|
|
2801
3197
|
return added;
|
|
2802
3198
|
}
|
|
2803
3199
|
|
|
@@ -3013,6 +3409,9 @@ export class DataTable {
|
|
|
3013
3409
|
if (colIdx === 0 && typeof this.config.rowIcon === 'function') {
|
|
3014
3410
|
this._prependRowIcon(td, this.config.rowIcon(row, globalIdx));
|
|
3015
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);
|
|
3016
3415
|
|
|
3017
3416
|
// Cell-level right-click hook (P2). Fires before the row-level
|
|
3018
3417
|
// hook and the built-in context menu — the caller can
|
|
@@ -3132,6 +3531,8 @@ export class DataTable {
|
|
|
3132
3531
|
// 0.5.0. The row's data changed, so its classes, attributes, key and
|
|
3133
3532
|
// open mark are asked again (a key paused, a delivery that died).
|
|
3134
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);
|
|
3135
3536
|
|
|
3136
3537
|
if (focusedCol >= 0 && tr.children[focusedCol]) {
|
|
3137
3538
|
tr.children[focusedCol].focus?.();
|
|
@@ -3139,6 +3540,1168 @@ export class DataTable {
|
|
|
3139
3540
|
return true;
|
|
3140
3541
|
}
|
|
3141
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
|
+
|
|
3142
4705
|
// ─────────────────────────────────────────────────────────────────
|
|
3143
4706
|
// 0.5.0 — decoration, states and the typed columns
|
|
3144
4707
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -3371,6 +4934,10 @@ export class DataTable {
|
|
|
3371
4934
|
tr.__rowKey = this._rowKey(row, rowIdx);
|
|
3372
4935
|
}
|
|
3373
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
|
+
}
|
|
3374
4941
|
}
|
|
3375
4942
|
|
|
3376
4943
|
/** The open mark on one row, from `_activeKey`. */
|
|
@@ -3575,6 +5142,30 @@ export class DataTable {
|
|
|
3575
5142
|
: 0;
|
|
3576
5143
|
natural[i] = Math.max(body, head);
|
|
3577
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;
|
|
3578
5169
|
|
|
3579
5170
|
for (const tr of sampled) tr.remove();
|
|
3580
5171
|
headerTable.classList.remove('twm-dt-measuring');
|
|
@@ -3763,6 +5354,7 @@ export class DataTable {
|
|
|
3763
5354
|
firstIdx,
|
|
3764
5355
|
overrides: this._colWidths,
|
|
3765
5356
|
fit: this.config.columnFit,
|
|
5357
|
+
floors: this._filterFloors,
|
|
3766
5358
|
});
|
|
3767
5359
|
const total = widths.reduce((a, b) => a + b, 0);
|
|
3768
5360
|
|
|
@@ -3872,6 +5464,11 @@ export class DataTable {
|
|
|
3872
5464
|
const n = natural.length;
|
|
3873
5465
|
const firstIdx = opts.firstIdx ?? 0;
|
|
3874
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));
|
|
3875
5472
|
// Anything that is not the literal opt-in is the historical behaviour.
|
|
3876
5473
|
// Read once, so an embedder that passes a typo gets the old layout
|
|
3877
5474
|
// rather than half of each.
|
|
@@ -3952,7 +5549,7 @@ export class DataTable {
|
|
|
3952
5549
|
const budget = avail - protectedSum;
|
|
3953
5550
|
if (budget < shrinkable.length * FLOOR) {
|
|
3954
5551
|
// Even at the floor we overflow → let the body scroll sideways.
|
|
3955
|
-
for (const i of shrinkable) widths[i] =
|
|
5552
|
+
for (const i of shrinkable) widths[i] = floorOf(i);
|
|
3956
5553
|
return widths;
|
|
3957
5554
|
}
|
|
3958
5555
|
// Max-min fair allocation: the narrow columns keep their content,
|
|
@@ -3969,7 +5566,7 @@ export class DataTable {
|
|
|
3969
5566
|
} else {
|
|
3970
5567
|
const level = Math.max(FLOOR, fair);
|
|
3971
5568
|
for (let j = k; j < shrinkable.length; j++) {
|
|
3972
|
-
widths[shrinkable[j]] = level;
|
|
5569
|
+
widths[shrinkable[j]] = Math.max(level, floorOf(shrinkable[j]));
|
|
3973
5570
|
}
|
|
3974
5571
|
// Dump any rounding remainder onto the widest column.
|
|
3975
5572
|
const leftover = remaining - colsLeft * level;
|
|
@@ -4255,6 +5852,9 @@ export class DataTable {
|
|
|
4255
5852
|
: bodyTable.querySelectorAll('tbody td');
|
|
4256
5853
|
cells.forEach((td) => {
|
|
4257
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;
|
|
4258
5858
|
const clipped = td.scrollWidth > td.clientWidth + 1;
|
|
4259
5859
|
if (clipped) {
|
|
4260
5860
|
if (!td.title) td.title = td.textContent;
|
|
@@ -4319,6 +5919,25 @@ export class DataTable {
|
|
|
4319
5919
|
this._installActivation(tbody, table);
|
|
4320
5920
|
}
|
|
4321
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
|
+
|
|
4322
5941
|
if (copyable) {
|
|
4323
5942
|
const handleContextMenu = (event) => {
|
|
4324
5943
|
const rowEl = event.target.closest('tr');
|
|
@@ -4326,6 +5945,12 @@ export class DataTable {
|
|
|
4326
5945
|
this._hideContextMenu();
|
|
4327
5946
|
return;
|
|
4328
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
|
+
}
|
|
4329
5954
|
|
|
4330
5955
|
event.preventDefault();
|
|
4331
5956
|
event.stopPropagation();
|