@zakkster/lite-table 1.0.0 → 1.2.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/CHANGELOG.md +344 -0
- package/README.md +564 -19
- package/Table.d.ts +258 -1
- package/Table.js +1644 -116
- package/llms.txt +291 -6
- package/package.json +15 -9
package/Table.js
CHANGED
|
@@ -90,49 +90,80 @@ function defaultCompare(a, b) {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
* Create a disposable scope. Tracks
|
|
94
|
-
* and arbitrary cleanups. `dispose()` runs
|
|
93
|
+
* Create a disposable scope. Tracks signals, computeds, effects, event
|
|
94
|
+
* listeners, and arbitrary user cleanups. `dispose()` runs them in reverse
|
|
95
|
+
* order, once.
|
|
96
|
+
*
|
|
97
|
+
* Implementation note: lite-signal returns CALLABLE handles for signals and
|
|
98
|
+
* computeds. To free them we must call the lite-signal `dispose()` (imported
|
|
99
|
+
* here as `disposeNode`) on the handle -- calling the handle as a function
|
|
100
|
+
* just reads it. Effect disposers are themselves the cleanup function; event
|
|
101
|
+
* remover thunks and user onCleanup callbacks are likewise just called.
|
|
102
|
+
*
|
|
103
|
+
* Cleanups are recorded as discriminated entries so the dispose loop knows
|
|
104
|
+
* which path to take per entry. The previous (v1.0.0) implementation pushed
|
|
105
|
+
* raw signal handles and tried to call them on cleanup -- which silently
|
|
106
|
+
* read them instead of disposing, leaving the nodes pinned in the lite-signal
|
|
107
|
+
* registry across createTable -> dispose() cycles (a real leak; visible only
|
|
108
|
+
* when many tables are created in one process, e.g. tests).
|
|
95
109
|
*/
|
|
96
110
|
function createScope() {
|
|
97
|
-
const
|
|
111
|
+
const KIND_NODE = 1; // lite-signal handle (signal or computed)
|
|
112
|
+
const KIND_THUNK = 2; // plain function: effect disposer, remover, user cleanup
|
|
113
|
+
// Two parallel arrays keep the per-entry shape allocation-free; one
|
|
114
|
+
// slot per cleanup is two integers' worth of memory, no per-entry obj.
|
|
115
|
+
const cleanupKinds = [];
|
|
116
|
+
const cleanupTargets = [];
|
|
98
117
|
let disposed = false;
|
|
118
|
+
|
|
119
|
+
function pushNode(handle) {
|
|
120
|
+
cleanupKinds.push(KIND_NODE);
|
|
121
|
+
cleanupTargets.push(handle);
|
|
122
|
+
}
|
|
123
|
+
function pushThunk(fn) {
|
|
124
|
+
cleanupKinds.push(KIND_THUNK);
|
|
125
|
+
cleanupTargets.push(fn);
|
|
126
|
+
}
|
|
127
|
+
|
|
99
128
|
const scope = {
|
|
100
129
|
signal(initial, opts) {
|
|
101
130
|
const s = signal(initial, opts);
|
|
102
|
-
|
|
131
|
+
pushNode(s);
|
|
103
132
|
return s;
|
|
104
133
|
},
|
|
105
134
|
computed(fn, opts) {
|
|
106
135
|
const c = computed(fn, opts);
|
|
107
|
-
|
|
136
|
+
pushNode(c);
|
|
108
137
|
return c;
|
|
109
138
|
},
|
|
110
139
|
effect(fn) {
|
|
111
140
|
const d = effect(fn);
|
|
112
|
-
|
|
141
|
+
pushThunk(d);
|
|
113
142
|
return d;
|
|
114
143
|
},
|
|
115
144
|
on(el, type, handler, opts) {
|
|
116
145
|
el.addEventListener(type, handler, opts);
|
|
117
146
|
const off = () => el.removeEventListener(type, handler, opts);
|
|
118
|
-
|
|
147
|
+
pushThunk(off);
|
|
119
148
|
return off;
|
|
120
149
|
},
|
|
121
150
|
onCleanup(fn) {
|
|
122
|
-
|
|
151
|
+
pushThunk(fn);
|
|
123
152
|
}
|
|
124
153
|
};
|
|
125
154
|
function dispose() {
|
|
126
155
|
if (disposed) return;
|
|
127
156
|
disposed = true;
|
|
128
|
-
for (let i =
|
|
129
|
-
const
|
|
157
|
+
for (let i = cleanupKinds.length - 1; i >= 0; i--) {
|
|
158
|
+
const k = cleanupKinds[i];
|
|
159
|
+
const t = cleanupTargets[i];
|
|
130
160
|
try {
|
|
131
|
-
if (
|
|
132
|
-
else
|
|
161
|
+
if (k === KIND_NODE) disposeNode(t);
|
|
162
|
+
else t();
|
|
133
163
|
} catch (_e) { /* per-handler */ }
|
|
134
164
|
}
|
|
135
|
-
|
|
165
|
+
cleanupKinds.length = 0;
|
|
166
|
+
cleanupTargets.length = 0;
|
|
136
167
|
}
|
|
137
168
|
return { scope, dispose };
|
|
138
169
|
}
|
|
@@ -169,6 +200,29 @@ function createColumnState(def, scope, defaults) {
|
|
|
169
200
|
pinnable: def.pinnable !== false,
|
|
170
201
|
hideable: def.hideable !== false,
|
|
171
202
|
reorderable: def.reorderable !== false,
|
|
203
|
+
// M2: cell editing opt-in. False by default; columns that drive
|
|
204
|
+
// computed values (accessor with no underlying field) should stay
|
|
205
|
+
// false because there's nowhere to write back to.
|
|
206
|
+
editable: def.editable === true,
|
|
207
|
+
// M2: per-column filter opt-in. False by default.
|
|
208
|
+
filterable: def.filterable === true,
|
|
209
|
+
// M2: optional custom filter predicate. (value, query, row) => boolean.
|
|
210
|
+
// value is the column's value (accessor-resolved). query is the trimmed
|
|
211
|
+
// current filter string. row is the full row (handy for cross-field
|
|
212
|
+
// filters or when the consumer wants more than the cell value).
|
|
213
|
+
// Default: case-insensitive substring match on the stringified value.
|
|
214
|
+
filter: typeof def.filter === "function" ? def.filter : null,
|
|
215
|
+
filterPlaceholder: typeof def.filterPlaceholder === "string" ? def.filterPlaceholder : null,
|
|
216
|
+
// M3: aggregate spec for grouped views. One of the built-in strings
|
|
217
|
+
// "sum" | "avg" | "min" | "max" | "count", or a custom reducer
|
|
218
|
+
// (rows, col) => any. `null` (default) means "no aggregate" -- the
|
|
219
|
+
// column is shown blank in group-header + grand-total rows.
|
|
220
|
+
aggregate: def.aggregate != null ? def.aggregate : null,
|
|
221
|
+
// M3: display formatter for aggregate values in group-header +
|
|
222
|
+
// grand-total rows. (value, col, count) => string. Only affects
|
|
223
|
+
// display -- `entry.aggregates.get(key)` still returns the raw value
|
|
224
|
+
// so consumers can format it themselves for export etc.
|
|
225
|
+
aggregateFormat: typeof def.aggregateFormat === "function" ? def.aggregateFormat : null,
|
|
172
226
|
minWidth,
|
|
173
227
|
maxWidth,
|
|
174
228
|
width,
|
|
@@ -244,7 +298,11 @@ export function createTable(config) {
|
|
|
244
298
|
rowHeight = 32,
|
|
245
299
|
overscan = 4,
|
|
246
300
|
initialFocus = null,
|
|
247
|
-
initialSort = []
|
|
301
|
+
initialSort = [],
|
|
302
|
+
// M2: cell-edit hook. Fires on commit with the row + column key +
|
|
303
|
+
// new + old value. Consumer mutates their row data (or stores the
|
|
304
|
+
// change in a backend); the table itself does NOT mutate rows.
|
|
305
|
+
onCellEdit = null
|
|
248
306
|
} = config;
|
|
249
307
|
|
|
250
308
|
if (typeof getRowId !== "function") {
|
|
@@ -391,9 +449,59 @@ export function createTable(config) {
|
|
|
391
449
|
return m;
|
|
392
450
|
});
|
|
393
451
|
|
|
452
|
+
// --- Filters (M2) ---
|
|
453
|
+
// Reactive Map<columnKey, string>. We use a Map so we can clear and add
|
|
454
|
+
// without re-allocating an object literal per change, but we wrap mutations
|
|
455
|
+
// in a fresh-Map .set() call (Object.is inequality is what notifies
|
|
456
|
+
// downstream computeds; mutating in place would silently skip propagation).
|
|
457
|
+
const columnFilters = scope.signal(new Map());
|
|
458
|
+
|
|
459
|
+
function _filterMatchesAll(row) {
|
|
460
|
+
const filters = columnFilters();
|
|
461
|
+
if (filters.size === 0) return true;
|
|
462
|
+
for (const [key, raw] of filters) {
|
|
463
|
+
const q = typeof raw === "string" ? raw.trim() : "";
|
|
464
|
+
if (q.length === 0) continue;
|
|
465
|
+
const col = columnsByKey.get(key);
|
|
466
|
+
if (!col || !col.filterable) continue;
|
|
467
|
+
const v = readCell(col, row);
|
|
468
|
+
const pred = col.filter;
|
|
469
|
+
if (pred) {
|
|
470
|
+
if (!pred(v, q, row)) return false;
|
|
471
|
+
} else {
|
|
472
|
+
// Default: case-insensitive substring on stringified value.
|
|
473
|
+
// null/undefined become empty -- they fail any non-empty filter.
|
|
474
|
+
const s = (v === null || v === undefined) ? "" : String(v);
|
|
475
|
+
if (s.toLowerCase().indexOf(q.toLowerCase()) < 0) return false;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Reactive: filtered source. visibleRows now sorts THIS, not raw rowsGetter.
|
|
482
|
+
// When no filter is active, returns the source array as-is (identity) to
|
|
483
|
+
// avoid an unnecessary O(N) allocation. When any filter has a non-empty
|
|
484
|
+
// query, walks the source once and produces a fresh filtered array.
|
|
485
|
+
const filteredRows = scope.computed(() => {
|
|
486
|
+
const src = rowsGetter();
|
|
487
|
+
const filters = columnFilters();
|
|
488
|
+
// Fast path: no filters at all OR every filter is empty.
|
|
489
|
+
if (filters.size === 0) return src;
|
|
490
|
+
let hasActive = false;
|
|
491
|
+
for (const v of filters.values()) {
|
|
492
|
+
if (typeof v === "string" && v.trim().length > 0) { hasActive = true; break; }
|
|
493
|
+
}
|
|
494
|
+
if (!hasActive) return src;
|
|
495
|
+
const out = [];
|
|
496
|
+
for (let i = 0; i < src.length; i++) {
|
|
497
|
+
if (_filterMatchesAll(src[i])) out.push(src[i]);
|
|
498
|
+
}
|
|
499
|
+
return out;
|
|
500
|
+
});
|
|
501
|
+
|
|
394
502
|
// --- Sort ---
|
|
395
503
|
// Sort chain: list of {key, dir}. visibleRows applies it as a stable
|
|
396
|
-
// multi-key sort to the source
|
|
504
|
+
// multi-key sort to the filtered source.
|
|
397
505
|
const sortChain = scope.signal(
|
|
398
506
|
Array.isArray(initialSort)
|
|
399
507
|
? initialSort.filter((s) => s && columnsByKey.has(s.key))
|
|
@@ -407,44 +515,9 @@ export function createTable(config) {
|
|
|
407
515
|
// stability across the multi-key chain regardless of engine.
|
|
408
516
|
let _sortIdxBuf = null;
|
|
409
517
|
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
if (!chain.length) return src;
|
|
414
|
-
const n = src.length;
|
|
415
|
-
|
|
416
|
-
if (!_sortIdxBuf || _sortIdxBuf.length < n) {
|
|
417
|
-
// Grow factor of 2 to amortize reallocation.
|
|
418
|
-
const cap = Math.max(n, _sortIdxBuf ? _sortIdxBuf.length * 2 : 1024);
|
|
419
|
-
_sortIdxBuf = new Uint32Array(cap);
|
|
420
|
-
}
|
|
421
|
-
const view = _sortIdxBuf.subarray(0, n);
|
|
422
|
-
for (let i = 0; i < n; i++) view[i] = i;
|
|
423
|
-
|
|
424
|
-
view.sort((iA, iB) => {
|
|
425
|
-
const rowA = src[iA], rowB = src[iB];
|
|
426
|
-
for (let k = 0; k < chain.length; k++) {
|
|
427
|
-
const entry = chain[k];
|
|
428
|
-
const col = columnsByKey.get(entry.key);
|
|
429
|
-
if (!col) continue;
|
|
430
|
-
const av = readCell(col, rowA);
|
|
431
|
-
const bv = readCell(col, rowB);
|
|
432
|
-
const c = col.compare(av, bv);
|
|
433
|
-
if (c !== 0) return entry.dir === "desc" ? -c : c;
|
|
434
|
-
}
|
|
435
|
-
return iA - iB;
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
// Single output allocation: an array of N row references (not row copies).
|
|
439
|
-
// Returning a fresh array each time preserves Object.is inequality so
|
|
440
|
-
// downstream computeds re-evaluate. Reusing this array across calls
|
|
441
|
-
// would silently break consumers that hold the prior reference.
|
|
442
|
-
const out = new Array(n);
|
|
443
|
-
for (let i = 0; i < n; i++) out[i] = src[view[i]];
|
|
444
|
-
return out;
|
|
445
|
-
});
|
|
446
|
-
|
|
447
|
-
const rowCount = scope.computed(() => visibleRows().length);
|
|
518
|
+
// (Sort buffer + `_sortedFilteredRows` + `visibleRows` + `rowCount` are
|
|
519
|
+
// defined inside the M3 grouping block below, since they now interact
|
|
520
|
+
// with `groupedRows`/`visibleEntries`.)
|
|
448
521
|
|
|
449
522
|
function toggleSort(key, opts) {
|
|
450
523
|
const col = columnsByKey.get(key);
|
|
@@ -452,22 +525,50 @@ export function createTable(config) {
|
|
|
452
525
|
const chain = sortChain();
|
|
453
526
|
const additive = opts && opts.additive === true;
|
|
454
527
|
const existing = chain.findIndex((e) => e.key === key);
|
|
455
|
-
|
|
456
|
-
const nextDir = existing < 0
|
|
457
|
-
? "asc"
|
|
458
|
-
: chain[existing].dir === "asc" ? "desc" : null;
|
|
528
|
+
|
|
459
529
|
if (additive) {
|
|
530
|
+
// Shift-click: 3-state cycle per chain entry.
|
|
531
|
+
// not in chain -> append at asc
|
|
532
|
+
// in chain asc -> flip to desc
|
|
533
|
+
// in chain desc -> remove from chain
|
|
534
|
+
// This is how chain membership is MANAGED -- it can both grow
|
|
535
|
+
// and shrink the chain.
|
|
460
536
|
const next = chain.slice();
|
|
461
537
|
if (existing < 0) {
|
|
462
|
-
next.push({ key, dir:
|
|
463
|
-
} else if (
|
|
464
|
-
next
|
|
538
|
+
next.push({ key, dir: "asc" });
|
|
539
|
+
} else if (chain[existing].dir === "asc") {
|
|
540
|
+
next[existing] = { key, dir: "desc" };
|
|
465
541
|
} else {
|
|
466
|
-
next
|
|
542
|
+
next.splice(existing, 1);
|
|
467
543
|
}
|
|
468
544
|
sortChain.set(next);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Plain click: drives the PRIMARY sort, never silently dismantles a
|
|
549
|
+
// user-built multi-column chain.
|
|
550
|
+
if (existing < 0) {
|
|
551
|
+
// Column not in chain: replace whole chain with single-col asc.
|
|
552
|
+
// Lets the user re-anchor primary sort with one click.
|
|
553
|
+
sortChain.set([{ key, dir: "asc" }]);
|
|
554
|
+
} else if (chain.length === 1) {
|
|
555
|
+
// Sole entry: legacy 3-state cycle (asc -> desc -> cleared).
|
|
556
|
+
// Some users rely on a third plain click to fully unsort.
|
|
557
|
+
sortChain.set(chain[0].dir === "asc"
|
|
558
|
+
? [{ key, dir: "desc" }]
|
|
559
|
+
: []);
|
|
469
560
|
} else {
|
|
470
|
-
|
|
561
|
+
// Column is already part of a MULTI-column chain: toggle just
|
|
562
|
+
// that entry's direction (asc <-> desc). No removal -- the user
|
|
563
|
+
// built this chain on purpose; one stray plain-click should not
|
|
564
|
+
// tear it down. Removal is intentional via shift-click (cycle to
|
|
565
|
+
// remove) or `clearSort()`.
|
|
566
|
+
const next = chain.slice();
|
|
567
|
+
next[existing] = {
|
|
568
|
+
key,
|
|
569
|
+
dir: chain[existing].dir === "asc" ? "desc" : "asc"
|
|
570
|
+
};
|
|
571
|
+
sortChain.set(next);
|
|
471
572
|
}
|
|
472
573
|
}
|
|
473
574
|
|
|
@@ -491,6 +592,464 @@ export function createTable(config) {
|
|
|
491
592
|
}
|
|
492
593
|
function clearSort() { sortChain.set([]); }
|
|
493
594
|
|
|
595
|
+
// =========================================================================
|
|
596
|
+
// --- Grouping + aggregation (M3) -----------------------------------------
|
|
597
|
+
// =========================================================================
|
|
598
|
+
//
|
|
599
|
+
// Pipeline slots between filter and sort:
|
|
600
|
+
//
|
|
601
|
+
// rowsGetter -> filteredRows -> [groupedRows -> visibleEntries] -> visibleRows
|
|
602
|
+
// | |
|
|
603
|
+
// | +-- sticky headers / grand total
|
|
604
|
+
// +-- sort applies WITHIN each leaf group
|
|
605
|
+
//
|
|
606
|
+
// When `groupBy()` is empty the pipeline short-circuits and behaves
|
|
607
|
+
// identically to 1.1.0 -- `groupedRows` is `null`, `visibleEntries` is
|
|
608
|
+
// built by wrapping `sortedFilteredRows` in `{type:"data", row}` cheaply,
|
|
609
|
+
// and `visibleRows` is just those rows without the wrapper. Non-grouping
|
|
610
|
+
// tables pay nothing beyond one signal read.
|
|
611
|
+
//
|
|
612
|
+
// Aggregates are pure folds over a group's data rows. Multi-level groups
|
|
613
|
+
// recompute aggregates from LEAF rows at every depth rather than rolling
|
|
614
|
+
// up child aggregates -- this stays correct for non-associative reducers
|
|
615
|
+
// like median or "distinct count" that don't compose. For deep trees the
|
|
616
|
+
// constant factor is dominated by the leaf-row walk regardless, so the
|
|
617
|
+
// simpler implementation is also the faster-per-line one.
|
|
618
|
+
|
|
619
|
+
// ---- Group-path serialization ------------------------------------------
|
|
620
|
+
// Paths are arrays like ["Europe", "Books"]. We serialize with an ASCII
|
|
621
|
+
// Unit Separator (U+001F) that essentially never appears in user data,
|
|
622
|
+
// avoiding collisions with values that contain other punctuation. The
|
|
623
|
+
// signal-side always operates on path arrays; strings are the storage
|
|
624
|
+
// key for the collapsed-groups Set (Sets can't compare arrays by value).
|
|
625
|
+
const GROUP_PATH_SEP = "\x1f";
|
|
626
|
+
// Sentinel bucket key for null/undefined group values. Kept internal so
|
|
627
|
+
// consumers never see it -- they see the group's `value` as `null`.
|
|
628
|
+
const GROUP_NULL_KEY = "\x00__lt_null_group__";
|
|
629
|
+
function _pathStr(pathArr) { return pathArr.join(GROUP_PATH_SEP); }
|
|
630
|
+
|
|
631
|
+
// ---- Built-in aggregators ----------------------------------------------
|
|
632
|
+
// Each takes (rows, col) and returns the folded value. Null/undefined
|
|
633
|
+
// values are skipped except for `count`, which counts rows regardless.
|
|
634
|
+
// `avg` / `min` / `max` return `null` for empty groups so consumers can
|
|
635
|
+
// tell "no data" from "zero".
|
|
636
|
+
const AGGS = {
|
|
637
|
+
sum(rows, col) {
|
|
638
|
+
let s = 0;
|
|
639
|
+
for (let i = 0; i < rows.length; i++) {
|
|
640
|
+
const v = readCell(col, rows[i]);
|
|
641
|
+
if (typeof v === "number" && !isNaN(v)) s += v;
|
|
642
|
+
}
|
|
643
|
+
return s;
|
|
644
|
+
},
|
|
645
|
+
avg(rows, col) {
|
|
646
|
+
let s = 0, n = 0;
|
|
647
|
+
for (let i = 0; i < rows.length; i++) {
|
|
648
|
+
const v = readCell(col, rows[i]);
|
|
649
|
+
if (typeof v === "number" && !isNaN(v)) { s += v; n++; }
|
|
650
|
+
}
|
|
651
|
+
return n === 0 ? null : s / n;
|
|
652
|
+
},
|
|
653
|
+
min(rows, col) {
|
|
654
|
+
let m = null;
|
|
655
|
+
for (let i = 0; i < rows.length; i++) {
|
|
656
|
+
const v = readCell(col, rows[i]);
|
|
657
|
+
if (v == null) continue;
|
|
658
|
+
if (m === null || v < m) m = v;
|
|
659
|
+
}
|
|
660
|
+
return m;
|
|
661
|
+
},
|
|
662
|
+
max(rows, col) {
|
|
663
|
+
let m = null;
|
|
664
|
+
for (let i = 0; i < rows.length; i++) {
|
|
665
|
+
const v = readCell(col, rows[i]);
|
|
666
|
+
if (v == null) continue;
|
|
667
|
+
if (m === null || v > m) m = v;
|
|
668
|
+
}
|
|
669
|
+
return m;
|
|
670
|
+
},
|
|
671
|
+
count(rows /*, col */) { return rows.length; }
|
|
672
|
+
};
|
|
673
|
+
function _resolveAggregator(spec) {
|
|
674
|
+
if (typeof spec === "function") return spec;
|
|
675
|
+
if (typeof spec === "string" && AGGS[spec]) return AGGS[spec];
|
|
676
|
+
return null;
|
|
677
|
+
}
|
|
678
|
+
// Pre-resolved (columnKey -> aggregator fn) map. Rebuilt lazily on first
|
|
679
|
+
// read and again if columns changes -- but columns is static after
|
|
680
|
+
// createTable, so this is a one-shot in practice.
|
|
681
|
+
let _aggregators = null;
|
|
682
|
+
function _getAggregators() {
|
|
683
|
+
if (_aggregators !== null) return _aggregators;
|
|
684
|
+
_aggregators = new Map();
|
|
685
|
+
for (const col of columns) {
|
|
686
|
+
const fn = _resolveAggregator(col.aggregate);
|
|
687
|
+
if (fn) _aggregators.set(col.key, fn);
|
|
688
|
+
}
|
|
689
|
+
return _aggregators;
|
|
690
|
+
}
|
|
691
|
+
function _computeAggregates(rows) {
|
|
692
|
+
const aggs = _getAggregators();
|
|
693
|
+
const result = new Map();
|
|
694
|
+
for (const [key, fn] of aggs) {
|
|
695
|
+
const col = columnsByKey.get(key);
|
|
696
|
+
if (!col) continue;
|
|
697
|
+
result.set(key, fn(rows, col));
|
|
698
|
+
}
|
|
699
|
+
return result;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---- Reactive state -----------------------------------------------------
|
|
703
|
+
// groupBy is a signal so consumers can flip grouping on/off at runtime.
|
|
704
|
+
// Accepts a string (single level), string[] (multi-level), or null/empty
|
|
705
|
+
// (no grouping). Normalized to an array of valid column keys internally.
|
|
706
|
+
function _normalizeGroupBy(v) {
|
|
707
|
+
const arr = v == null ? [] : (Array.isArray(v) ? v : [v]);
|
|
708
|
+
// Drop unknown keys silently -- lets consumers persist their groupBy
|
|
709
|
+
// to localStorage without a crash if a column was removed later.
|
|
710
|
+
const out = [];
|
|
711
|
+
for (const k of arr) {
|
|
712
|
+
if (typeof k === "string" && columnsByKey.has(k)) out.push(k);
|
|
713
|
+
}
|
|
714
|
+
return out;
|
|
715
|
+
}
|
|
716
|
+
const groupBy = scope.signal(_normalizeGroupBy(config.groupBy));
|
|
717
|
+
|
|
718
|
+
// collapsedGroups: Set<pathStr>. Any group whose pathStr is present is
|
|
719
|
+
// rendered as a header but its subtree (subgroups + data rows) is not
|
|
720
|
+
// emitted into visibleEntries. Initial set can be supplied as an array
|
|
721
|
+
// of path arrays via `initialCollapsedGroups`.
|
|
722
|
+
const collapsedGroups = scope.signal(
|
|
723
|
+
(() => {
|
|
724
|
+
const init = config.initialCollapsedGroups;
|
|
725
|
+
const s = new Set();
|
|
726
|
+
if (Array.isArray(init)) {
|
|
727
|
+
for (const p of init) {
|
|
728
|
+
if (Array.isArray(p)) s.add(_pathStr(p.map(String)));
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
return s;
|
|
732
|
+
})()
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
// showGrandTotal: static bool. Making it reactive adds a fanout with
|
|
736
|
+
// near-zero benefit -- if a consumer wants runtime toggling they can
|
|
737
|
+
// re-mount. Guarded here to avoid accidental truthy configs.
|
|
738
|
+
const showGrandTotal = config.showGrandTotal === true;
|
|
739
|
+
|
|
740
|
+
// ---- Sort helper (used both by ungrouped path and leaf-group sort) -----
|
|
741
|
+
// Factored out of the ungrouped visibleRows so leaf groups can call it
|
|
742
|
+
// with their own row subset without allocating a new sort buffer.
|
|
743
|
+
// Returns a NEW array of row references; the input is not mutated.
|
|
744
|
+
function _sortRowsWithChain(src, chain) {
|
|
745
|
+
const n = src.length;
|
|
746
|
+
if (!chain.length || n < 2) return src.slice();
|
|
747
|
+
if (!_sortIdxBuf || _sortIdxBuf.length < n) {
|
|
748
|
+
const cap = Math.max(n, _sortIdxBuf ? _sortIdxBuf.length * 2 : 1024);
|
|
749
|
+
_sortIdxBuf = new Uint32Array(cap);
|
|
750
|
+
}
|
|
751
|
+
const view = _sortIdxBuf.subarray(0, n);
|
|
752
|
+
for (let i = 0; i < n; i++) view[i] = i;
|
|
753
|
+
view.sort((iA, iB) => {
|
|
754
|
+
const rowA = src[iA], rowB = src[iB];
|
|
755
|
+
for (let k = 0; k < chain.length; k++) {
|
|
756
|
+
const entry = chain[k];
|
|
757
|
+
const col = columnsByKey.get(entry.key);
|
|
758
|
+
if (!col) continue;
|
|
759
|
+
const av = readCell(col, rowA);
|
|
760
|
+
const bv = readCell(col, rowB);
|
|
761
|
+
const c = col.compare(av, bv);
|
|
762
|
+
if (c !== 0) return entry.dir === "desc" ? -c : c;
|
|
763
|
+
}
|
|
764
|
+
return iA - iB;
|
|
765
|
+
});
|
|
766
|
+
const out = new Array(n);
|
|
767
|
+
for (let i = 0; i < n; i++) out[i] = src[view[i]];
|
|
768
|
+
return out;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---- Group tree ---------------------------------------------------------
|
|
772
|
+
// Recursive partition. At each depth, buckets by (accessor-resolved)
|
|
773
|
+
// column value; group-key ordering is ascending on the raw bucket key.
|
|
774
|
+
// Null values bucket under GROUP_NULL_KEY and sort last so they're
|
|
775
|
+
// visually distinct.
|
|
776
|
+
function _partition(rows, keys, depth, parentPath) {
|
|
777
|
+
const groupKey = keys[depth];
|
|
778
|
+
const col = columnsByKey.get(groupKey);
|
|
779
|
+
// Insertion-order Map -> we later sort keys; using Map keeps values
|
|
780
|
+
// grouped without a hashmap of arrays.
|
|
781
|
+
const buckets = new Map();
|
|
782
|
+
for (let i = 0; i < rows.length; i++) {
|
|
783
|
+
const row = rows[i];
|
|
784
|
+
const v = col ? readCell(col, row) : row[groupKey];
|
|
785
|
+
const bucketKey = v == null ? GROUP_NULL_KEY : v;
|
|
786
|
+
let bucket = buckets.get(bucketKey);
|
|
787
|
+
if (!bucket) { bucket = []; buckets.set(bucketKey, bucket); }
|
|
788
|
+
bucket.push(row);
|
|
789
|
+
}
|
|
790
|
+
const sortedKeys = [...buckets.keys()].sort((a, b) => {
|
|
791
|
+
// Nulls last, regardless of asc/desc semantics.
|
|
792
|
+
if (a === GROUP_NULL_KEY) return 1;
|
|
793
|
+
if (b === GROUP_NULL_KEY) return -1;
|
|
794
|
+
if (a < b) return -1;
|
|
795
|
+
if (a > b) return 1;
|
|
796
|
+
return 0;
|
|
797
|
+
});
|
|
798
|
+
const nodes = new Array(sortedKeys.length);
|
|
799
|
+
const isLeaf = depth + 1 >= keys.length;
|
|
800
|
+
const chain = sortChain();
|
|
801
|
+
for (let i = 0; i < sortedKeys.length; i++) {
|
|
802
|
+
const bk = sortedKeys[i];
|
|
803
|
+
const bucketRows = buckets.get(bk);
|
|
804
|
+
const path = parentPath.length === 0
|
|
805
|
+
? [String(bk === GROUP_NULL_KEY ? "" : bk)]
|
|
806
|
+
: parentPath.concat([String(bk === GROUP_NULL_KEY ? "" : bk)]);
|
|
807
|
+
const pathStr = _pathStr(path);
|
|
808
|
+
const node = {
|
|
809
|
+
depth,
|
|
810
|
+
key: groupKey,
|
|
811
|
+
value: bk === GROUP_NULL_KEY ? null : bk,
|
|
812
|
+
path,
|
|
813
|
+
pathStr,
|
|
814
|
+
count: bucketRows.length,
|
|
815
|
+
// Aggregates always fold over LEAF rows (this bucket's
|
|
816
|
+
// recursive descendants when non-leaf, or its own rows when
|
|
817
|
+
// leaf -- same set at every level for pure aggregators).
|
|
818
|
+
aggregates: _computeAggregates(bucketRows),
|
|
819
|
+
subGroups: null,
|
|
820
|
+
rows: null
|
|
821
|
+
};
|
|
822
|
+
if (isLeaf) {
|
|
823
|
+
node.rows = _sortRowsWithChain(bucketRows, chain);
|
|
824
|
+
} else {
|
|
825
|
+
node.subGroups = _partition(bucketRows, keys, depth + 1, path);
|
|
826
|
+
}
|
|
827
|
+
nodes[i] = node;
|
|
828
|
+
}
|
|
829
|
+
return nodes;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// groupedRows: null when no grouping, GroupNode[] when active.
|
|
833
|
+
const groupedRows = scope.computed(() => {
|
|
834
|
+
const keys = groupBy();
|
|
835
|
+
if (keys.length === 0) return null;
|
|
836
|
+
const src = filteredRows();
|
|
837
|
+
// Re-read sortChain via the sortChain read inside _partition -> that
|
|
838
|
+
// makes leaf-group sort reactive. If we called _sortRowsWithChain
|
|
839
|
+
// here directly we'd need explicit sortChain() first.
|
|
840
|
+
return _partition(src, keys, 0, []);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
// ---- Emit tree to a flat entries array ---------------------------------
|
|
844
|
+
// A depth-first walk that respects the current collapsed-groups Set.
|
|
845
|
+
// Entries are one of:
|
|
846
|
+
// { type: "data", row }
|
|
847
|
+
// { type: "group-header", depth, key, value, path, pathStr, count,
|
|
848
|
+
// aggregates, isCollapsed }
|
|
849
|
+
// { type: "grand-total", aggregates, count }
|
|
850
|
+
// The mount layer dispatches per-slot rendering on `entry.type`.
|
|
851
|
+
function _emitTree(nodes, out, collapsed) {
|
|
852
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
853
|
+
const node = nodes[i];
|
|
854
|
+
const isCollapsed = collapsed.has(node.pathStr);
|
|
855
|
+
out.push({
|
|
856
|
+
type: "group-header",
|
|
857
|
+
depth: node.depth,
|
|
858
|
+
key: node.key,
|
|
859
|
+
value: node.value,
|
|
860
|
+
path: node.path,
|
|
861
|
+
pathStr: node.pathStr,
|
|
862
|
+
count: node.count,
|
|
863
|
+
aggregates: node.aggregates,
|
|
864
|
+
isCollapsed
|
|
865
|
+
});
|
|
866
|
+
if (isCollapsed) continue;
|
|
867
|
+
if (node.subGroups) {
|
|
868
|
+
_emitTree(node.subGroups, out, collapsed);
|
|
869
|
+
} else {
|
|
870
|
+
const rows = node.rows;
|
|
871
|
+
for (let j = 0; j < rows.length; j++) {
|
|
872
|
+
out.push({ type: "data", row: rows[j] });
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Ungrouped -> `visibleRows` behavior, exactly like 1.1.0.
|
|
879
|
+
// Kept as a private computed so the ungrouped fast path doesn't have to
|
|
880
|
+
// build entries + strip them.
|
|
881
|
+
const _sortedFilteredRows = scope.computed(() => {
|
|
882
|
+
const src = filteredRows();
|
|
883
|
+
const chain = sortChain();
|
|
884
|
+
if (!chain.length) return src;
|
|
885
|
+
return _sortRowsWithChain(src, chain);
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
const visibleEntries = scope.computed(() => {
|
|
889
|
+
const tree = groupedRows();
|
|
890
|
+
const collapsed = collapsedGroups();
|
|
891
|
+
let entries;
|
|
892
|
+
if (tree === null) {
|
|
893
|
+
// Ungrouped: wrap each sorted-filtered row as a data entry.
|
|
894
|
+
const rows = _sortedFilteredRows();
|
|
895
|
+
entries = new Array(rows.length);
|
|
896
|
+
for (let i = 0; i < rows.length; i++) entries[i] = { type: "data", row: rows[i] };
|
|
897
|
+
} else {
|
|
898
|
+
entries = [];
|
|
899
|
+
_emitTree(tree, entries, collapsed);
|
|
900
|
+
}
|
|
901
|
+
if (showGrandTotal) {
|
|
902
|
+
// Grand-total aggregates ALWAYS fold over filteredRows (the
|
|
903
|
+
// whole visible-in-consumer's-sense dataset), regardless of
|
|
904
|
+
// collapse state. Consumers expect the total to be stable when
|
|
905
|
+
// they collapse a group.
|
|
906
|
+
const src = filteredRows();
|
|
907
|
+
entries.push({
|
|
908
|
+
type: "grand-total",
|
|
909
|
+
aggregates: _computeAggregates(src),
|
|
910
|
+
count: src.length
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
return entries;
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
// visibleRows: BACKWARDS COMPAT -- always returns just data rows in
|
|
917
|
+
// current display order. Ungrouped: same array as 1.1.0's visibleRows.
|
|
918
|
+
// Grouped: data rows extracted from visibleEntries (respects collapse).
|
|
919
|
+
const visibleRows = scope.computed(() => {
|
|
920
|
+
// Ungrouped fast path -- skip entries entirely.
|
|
921
|
+
if (groupBy().length === 0) return _sortedFilteredRows();
|
|
922
|
+
const entries = visibleEntries();
|
|
923
|
+
const out = [];
|
|
924
|
+
for (let i = 0; i < entries.length; i++) {
|
|
925
|
+
if (entries[i].type === "data") out.push(entries[i].row);
|
|
926
|
+
}
|
|
927
|
+
return out;
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
// rowCount: still counts DATA rows (backwards compat). Consumers that
|
|
931
|
+
// want the total including group headers should read entryCount().
|
|
932
|
+
const rowCount = scope.computed(() => visibleRows().length);
|
|
933
|
+
// entryCount: total emitted entries -- drives the virtual axis in the
|
|
934
|
+
// mount layer so group-header rows take vertical space in the scrolled
|
|
935
|
+
// content just like data rows do.
|
|
936
|
+
const entryCount = scope.computed(() => visibleEntries().length);
|
|
937
|
+
|
|
938
|
+
// ---- Grouping mutators -------------------------------------------------
|
|
939
|
+
function setGroupBy(v) {
|
|
940
|
+
const next = _normalizeGroupBy(v);
|
|
941
|
+
// Avoid unnecessary signal writes when the effective value is the
|
|
942
|
+
// same array of keys. Cheap len+scan comparison.
|
|
943
|
+
const cur = groupBy();
|
|
944
|
+
if (cur.length === next.length) {
|
|
945
|
+
let same = true;
|
|
946
|
+
for (let i = 0; i < cur.length; i++) {
|
|
947
|
+
if (cur[i] !== next[i]) { same = false; break; }
|
|
948
|
+
}
|
|
949
|
+
if (same) return;
|
|
950
|
+
}
|
|
951
|
+
groupBy.set(next);
|
|
952
|
+
// Prune collapsed paths whose top-level key is no longer part of
|
|
953
|
+
// groupBy -- otherwise stale entries linger in the Set forever.
|
|
954
|
+
// (We can't reason about deeper paths without walking the tree, and
|
|
955
|
+
// they're harmless since they'll never match a real path anyway.)
|
|
956
|
+
if (collapsedGroups().size > 0 && next.length === 0) {
|
|
957
|
+
collapsedGroups.set(new Set());
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
function _mutateCollapse(fn) {
|
|
961
|
+
const s = collapsedGroups();
|
|
962
|
+
const next = new Set(s);
|
|
963
|
+
fn(next);
|
|
964
|
+
// Only publish a new Set if membership actually changed -- keeps
|
|
965
|
+
// downstream computeds stable.
|
|
966
|
+
if (next.size !== s.size) { collapsedGroups.set(next); return; }
|
|
967
|
+
for (const k of next) { if (!s.has(k)) { collapsedGroups.set(next); return; } }
|
|
968
|
+
}
|
|
969
|
+
function collapseGroup(path) {
|
|
970
|
+
if (!Array.isArray(path)) return;
|
|
971
|
+
const key = _pathStr(path.map(String));
|
|
972
|
+
_mutateCollapse((s) => s.add(key));
|
|
973
|
+
}
|
|
974
|
+
function expandGroup(path) {
|
|
975
|
+
if (!Array.isArray(path)) return;
|
|
976
|
+
const key = _pathStr(path.map(String));
|
|
977
|
+
_mutateCollapse((s) => s.delete(key));
|
|
978
|
+
}
|
|
979
|
+
function toggleGroup(path) {
|
|
980
|
+
if (!Array.isArray(path)) return;
|
|
981
|
+
const key = _pathStr(path.map(String));
|
|
982
|
+
_mutateCollapse((s) => { if (s.has(key)) s.delete(key); else s.add(key); });
|
|
983
|
+
}
|
|
984
|
+
function collapseAllGroups() {
|
|
985
|
+
// Walk the current groupedRows tree and collect every group's pathStr.
|
|
986
|
+
// Only makes sense when grouping is active.
|
|
987
|
+
const tree = groupedRows();
|
|
988
|
+
if (tree === null) return;
|
|
989
|
+
const s = new Set();
|
|
990
|
+
(function walk(nodes) {
|
|
991
|
+
for (const n of nodes) {
|
|
992
|
+
s.add(n.pathStr);
|
|
993
|
+
if (n.subGroups) walk(n.subGroups);
|
|
994
|
+
}
|
|
995
|
+
})(tree);
|
|
996
|
+
collapsedGroups.set(s);
|
|
997
|
+
}
|
|
998
|
+
function expandAllGroups() { collapsedGroups.set(new Set()); }
|
|
999
|
+
|
|
1000
|
+
// Convenience: is a path currently collapsed? Fast enough that consumers
|
|
1001
|
+
// can bind it into row-level effects without indirection.
|
|
1002
|
+
function isGroupCollapsed(path) {
|
|
1003
|
+
if (!Array.isArray(path)) return false;
|
|
1004
|
+
return collapsedGroups().has(_pathStr(path.map(String)));
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Ancestor lookup for sticky-header rendering: given an entry index
|
|
1008
|
+
// (usually axis.start()), returns the group-header entries that CONTAIN
|
|
1009
|
+
// it -- one per depth level, deepest last. Returns [] for the ungrouped
|
|
1010
|
+
// path or when the target is above the first group header.
|
|
1011
|
+
function groupAncestryAt(entryIndex) {
|
|
1012
|
+
const entries = visibleEntries();
|
|
1013
|
+
if (entryIndex < 0 || entryIndex >= entries.length) return [];
|
|
1014
|
+
const target = entries[entryIndex];
|
|
1015
|
+
// If target is a group-header, its own row is what the mount is
|
|
1016
|
+
// rendering -- ancestors are strictly-shallower headers. If it's a
|
|
1017
|
+
// data row, ancestors are ALL group headers containing it.
|
|
1018
|
+
const maxDepth = target.type === "group-header" ? target.depth : Infinity;
|
|
1019
|
+
const active = [];
|
|
1020
|
+
for (let i = entryIndex; i >= 0; i--) {
|
|
1021
|
+
const e = entries[i];
|
|
1022
|
+
if (e.type === "group-header" && e.depth < maxDepth && active[e.depth] === undefined) {
|
|
1023
|
+
active[e.depth] = e;
|
|
1024
|
+
// Early exit once we've collected every needed level.
|
|
1025
|
+
let complete = true;
|
|
1026
|
+
for (let d = 0; d < maxDepth && d <= e.depth + 8 /* safety */; d++) {
|
|
1027
|
+
if (active[d] === undefined) { complete = false; break; }
|
|
1028
|
+
}
|
|
1029
|
+
if (complete && maxDepth !== Infinity) break;
|
|
1030
|
+
}
|
|
1031
|
+
if (e.type === "data" && active.length > 0) {
|
|
1032
|
+
// Data rows always come AFTER their headers. If we've filled
|
|
1033
|
+
// every shallower depth we can stop.
|
|
1034
|
+
let complete = true;
|
|
1035
|
+
for (let d = 0; d < active.length; d++) {
|
|
1036
|
+
if (active[d] === undefined) { complete = false; break; }
|
|
1037
|
+
}
|
|
1038
|
+
if (complete) break;
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
// Trim trailing undefined slots.
|
|
1042
|
+
const out = [];
|
|
1043
|
+
for (let i = 0; i < active.length; i++) {
|
|
1044
|
+
if (active[i] !== undefined) out.push(active[i]);
|
|
1045
|
+
}
|
|
1046
|
+
return out;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// =========================================================================
|
|
1050
|
+
// --- End grouping --------------------------------------------------------
|
|
1051
|
+
// =========================================================================
|
|
1052
|
+
|
|
494
1053
|
// --- Selection ---
|
|
495
1054
|
// The selection state is a PREDICATE, not a list of IDs. Two modes:
|
|
496
1055
|
//
|
|
@@ -762,6 +1321,309 @@ export function createTable(config) {
|
|
|
762
1321
|
columnOrder.set(order);
|
|
763
1322
|
}
|
|
764
1323
|
|
|
1324
|
+
// --- Filters (M2 mutators) ---
|
|
1325
|
+
function setColumnFilter(key, value) {
|
|
1326
|
+
const col = columnsByKey.get(key);
|
|
1327
|
+
if (!col || !col.filterable) return;
|
|
1328
|
+
const next = new Map(columnFilters());
|
|
1329
|
+
if (value === null || value === undefined || value === "") {
|
|
1330
|
+
next.delete(key);
|
|
1331
|
+
} else {
|
|
1332
|
+
next.set(key, String(value));
|
|
1333
|
+
}
|
|
1334
|
+
columnFilters.set(next);
|
|
1335
|
+
}
|
|
1336
|
+
function clearColumnFilters() {
|
|
1337
|
+
if (columnFilters().size === 0) return;
|
|
1338
|
+
columnFilters.set(new Map());
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
// --- Cell editing (M2) ---
|
|
1342
|
+
// editingCell points at the (rowId, columnKey) currently being edited,
|
|
1343
|
+
// or null. Like focusedCell, editing is keyed on row identity so it
|
|
1344
|
+
// survives scroll, sort, filter, slot recycling -- a cell that scrolls
|
|
1345
|
+
// out of the viewport stays "editing" if it scrolls back in.
|
|
1346
|
+
const editingCell = scope.signal(null);
|
|
1347
|
+
// editingDraft is the in-progress value being typed. mountTable writes to
|
|
1348
|
+
// it from `input` events on the contenteditable cell; the consumer rarely
|
|
1349
|
+
// needs to read this directly (commitEdit captures it for them).
|
|
1350
|
+
const editingDraft = scope.signal("");
|
|
1351
|
+
|
|
1352
|
+
function isEditing(rowId, columnKey) {
|
|
1353
|
+
const e = editingCell();
|
|
1354
|
+
if (e === null) return false;
|
|
1355
|
+
return e.rowId === rowId && e.columnKey === columnKey;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
function startEdit(rowId, columnKey) {
|
|
1359
|
+
const col = columnsByKey.get(columnKey);
|
|
1360
|
+
if (!col || !col.editable) return;
|
|
1361
|
+
// Already editing this exact cell -- no-op so an idempotent double-
|
|
1362
|
+
// click or programmatic call doesn't clobber the in-flight draft.
|
|
1363
|
+
const e = editingCell();
|
|
1364
|
+
if (e !== null && e.rowId === rowId && e.columnKey === columnKey) return;
|
|
1365
|
+
// Editing a different cell -- commit before switching so the user's
|
|
1366
|
+
// typed-in value isn't silently dropped on a tab-out / click-elsewhere.
|
|
1367
|
+
if (e !== null) {
|
|
1368
|
+
commitEdit();
|
|
1369
|
+
}
|
|
1370
|
+
// Seed the draft with the current cell value so the consumer's
|
|
1371
|
+
// onCellEdit gets a clean oldValue/newValue diff even if they don't
|
|
1372
|
+
// touch anything.
|
|
1373
|
+
const row = _findRowById(rowId);
|
|
1374
|
+
if (row !== undefined) {
|
|
1375
|
+
const v = readCell(col, row);
|
|
1376
|
+
editingDraft.set(v === null || v === undefined ? "" : String(v));
|
|
1377
|
+
} else {
|
|
1378
|
+
editingDraft.set("");
|
|
1379
|
+
}
|
|
1380
|
+
editingCell.set({ rowId, columnKey });
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
function commitEdit(explicitValue) {
|
|
1384
|
+
const e = editingCell();
|
|
1385
|
+
if (e === null) return;
|
|
1386
|
+
const col = columnsByKey.get(e.columnKey);
|
|
1387
|
+
const row = _findRowById(e.rowId);
|
|
1388
|
+
const newValue = arguments.length > 0 ? explicitValue : editingDraft();
|
|
1389
|
+
editingCell.set(null);
|
|
1390
|
+
editingDraft.set("");
|
|
1391
|
+
if (col && row !== undefined && onCellEdit) {
|
|
1392
|
+
const oldValue = readCell(col, row);
|
|
1393
|
+
|
|
1394
|
+
// No-op guard: skip the hook when nothing actually changed.
|
|
1395
|
+
// If newValue is a string (from contenteditable), coerce oldValue
|
|
1396
|
+
// to a string to prevent false positives (e.g., 100 !== "100").
|
|
1397
|
+
// If a non-string is explicitly passed, use standard strict equality.
|
|
1398
|
+
const isChanged = typeof newValue === "string"
|
|
1399
|
+
? String(oldValue) !== newValue
|
|
1400
|
+
: oldValue !== newValue;
|
|
1401
|
+
|
|
1402
|
+
if (isChanged) {
|
|
1403
|
+
try {
|
|
1404
|
+
onCellEdit({
|
|
1405
|
+
row,
|
|
1406
|
+
columnKey: e.columnKey,
|
|
1407
|
+
oldValue,
|
|
1408
|
+
newValue,
|
|
1409
|
+
});
|
|
1410
|
+
} catch (err) {
|
|
1411
|
+
try { console.error("lite-table: onCellEdit threw:", err); } catch (_) {}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
function cancelEdit() {
|
|
1418
|
+
if (editingCell() === null) return;
|
|
1419
|
+
editingCell.set(null);
|
|
1420
|
+
editingDraft.set("");
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
// Best-effort row lookup by id. Walks the master once -- O(N). The cell-
|
|
1424
|
+
// editing happy path uses this once per commit, not per keystroke, so the
|
|
1425
|
+
// linear scan is fine; if a consumer with millions of rows wants O(1)
|
|
1426
|
+
// they can build their own id->row index and pass it in via onCellEdit
|
|
1427
|
+
// by other means (or use selectedRows-style materializers).
|
|
1428
|
+
function _findRowById(rowId) {
|
|
1429
|
+
const src = rowsGetter();
|
|
1430
|
+
for (let i = 0; i < src.length; i++) {
|
|
1431
|
+
if (getRowId(src[i]) === rowId) return src[i];
|
|
1432
|
+
}
|
|
1433
|
+
return undefined;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// --- Export ---
|
|
1437
|
+
// Resolve the row source for an export call:
|
|
1438
|
+
// rows: "visible" => visibleRows() (post-sort/filter, post-pagination if you reactify it)
|
|
1439
|
+
// rows: "selected" => current selection materialized against the chosen source
|
|
1440
|
+
// rows: "all" => rowsGetter() (the original master)
|
|
1441
|
+
// rows: <array> => an explicit array (e.g. a snapshot)
|
|
1442
|
+
// Selection mode interacts naturally: passing rows: "selected" with
|
|
1443
|
+
// selectAll() active exports every row minus the blacklist, which is
|
|
1444
|
+
// typically what the user means when they Ctrl+A then "Export selected".
|
|
1445
|
+
function _resolveRows(rowsOpt) {
|
|
1446
|
+
if (Array.isArray(rowsOpt)) return rowsOpt;
|
|
1447
|
+
if (rowsOpt === "all") return rowsGetter();
|
|
1448
|
+
if (rowsOpt === "selected") return selectedRows(rowsGetter());
|
|
1449
|
+
// default "visible"
|
|
1450
|
+
return visibleRows();
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// Resolve column list. "visible" honors hidden + order; "all" walks
|
|
1454
|
+
// declared columns in declaration order regardless of hide/order state.
|
|
1455
|
+
// Array of keys = explicit projection.
|
|
1456
|
+
function _resolveColumns(colsOpt) {
|
|
1457
|
+
if (Array.isArray(colsOpt)) {
|
|
1458
|
+
const out = [];
|
|
1459
|
+
for (const k of colsOpt) {
|
|
1460
|
+
const c = columnsByKey.get(k);
|
|
1461
|
+
if (c) out.push(c);
|
|
1462
|
+
}
|
|
1463
|
+
return out;
|
|
1464
|
+
}
|
|
1465
|
+
if (colsOpt === "all") return columns.slice();
|
|
1466
|
+
// default "visible"
|
|
1467
|
+
return visibleColumns();
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
// Extract the displayed value for a cell: accessor() if present, else
|
|
1471
|
+
// row[key]. Used by both exports + a documented helper if you ever
|
|
1472
|
+
// want the same projection elsewhere (currently inlined for speed).
|
|
1473
|
+
function _cellValue(row, col) {
|
|
1474
|
+
return col.accessor ? col.accessor(row) : row[col.key];
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// CSV escape rule (RFC 4180): if a field contains the delimiter, a
|
|
1478
|
+
// quote, CR, or LF, it must be enclosed in quotes and any inner quote
|
|
1479
|
+
// doubled. We additionally accept a custom delimiter + quote char.
|
|
1480
|
+
function _csvEscape(value, delimiter, quote) {
|
|
1481
|
+
if (value === null || value === undefined) return "";
|
|
1482
|
+
const s = typeof value === "string" ? value : String(value);
|
|
1483
|
+
if (s.length === 0) return "";
|
|
1484
|
+
// The quote-doubling is a regex with the configured quote char.
|
|
1485
|
+
// Build the matcher lazily; for the default '"' delimiter ',' this
|
|
1486
|
+
// is the same hot path every call, so V8 caches the regex behind
|
|
1487
|
+
// the literal anyway.
|
|
1488
|
+
const needsQuote =
|
|
1489
|
+
s.indexOf(delimiter) !== -1 ||
|
|
1490
|
+
s.indexOf(quote) !== -1 ||
|
|
1491
|
+
s.indexOf("\n") !== -1 ||
|
|
1492
|
+
s.indexOf("\r") !== -1;
|
|
1493
|
+
if (!needsQuote) return s;
|
|
1494
|
+
// Double any embedded quote
|
|
1495
|
+
let escaped = "";
|
|
1496
|
+
for (let i = 0; i < s.length; i++) {
|
|
1497
|
+
const ch = s[i];
|
|
1498
|
+
escaped += ch === quote ? quote + quote : ch;
|
|
1499
|
+
}
|
|
1500
|
+
return quote + escaped + quote;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Export rows to a CSV string.
|
|
1505
|
+
*
|
|
1506
|
+
* @param {object} [opts]
|
|
1507
|
+
* @param {"visible"|"all"|"selected"|Array} [opts.rows="visible"]
|
|
1508
|
+
* Which rows to export.
|
|
1509
|
+
* @param {"visible"|"all"|Array<string>} [opts.columns="visible"]
|
|
1510
|
+
* Which columns to project. Array values are column keys.
|
|
1511
|
+
* @param {string} [opts.delimiter=","]
|
|
1512
|
+
* Field separator. Common alternatives: "\t" for TSV, ";" for
|
|
1513
|
+
* regional CSV.
|
|
1514
|
+
* @param {string} [opts.quote='"']
|
|
1515
|
+
* Quote character (per RFC 4180).
|
|
1516
|
+
* @param {boolean} [opts.headers=true]
|
|
1517
|
+
* Emit a header row.
|
|
1518
|
+
* @param {string} [opts.newline="\r\n"]
|
|
1519
|
+
* Line separator. RFC 4180 says CRLF; LF works fine for most
|
|
1520
|
+
* consumers and tends to be what spreadsheet imports prefer
|
|
1521
|
+
* when opened on macOS / Linux.
|
|
1522
|
+
* @param {boolean} [opts.bom=false]
|
|
1523
|
+
* Prepend a UTF-8 BOM. Excel on Windows uses this to detect
|
|
1524
|
+
* UTF-8 encoding for non-ASCII content.
|
|
1525
|
+
* @param {(row:any, col:ColumnState) => unknown} [opts.formatter]
|
|
1526
|
+
* Optional cell formatter run BEFORE the value is stringified
|
|
1527
|
+
* and CSV-escaped. Receives the raw row + the column state.
|
|
1528
|
+
* Use this for date formatting, number locales, etc.
|
|
1529
|
+
* @returns {string}
|
|
1530
|
+
*/
|
|
1531
|
+
function exportCsv(opts) {
|
|
1532
|
+
opts = opts || {};
|
|
1533
|
+
const rows = _resolveRows(opts.rows);
|
|
1534
|
+
const cols = _resolveColumns(opts.columns);
|
|
1535
|
+
const delimiter = typeof opts.delimiter === "string" && opts.delimiter.length > 0 ? opts.delimiter : ",";
|
|
1536
|
+
const quote = typeof opts.quote === "string" && opts.quote.length > 0 ? opts.quote : '"';
|
|
1537
|
+
const headers = opts.headers !== false;
|
|
1538
|
+
const newline = typeof opts.newline === "string" ? opts.newline : "\r\n";
|
|
1539
|
+
const bom = opts.bom === true;
|
|
1540
|
+
const fmt = typeof opts.formatter === "function" ? opts.formatter : null;
|
|
1541
|
+
|
|
1542
|
+
// Pre-size for one pass append; the final string is built by Array.join
|
|
1543
|
+
// to avoid the O(N^2) string concat trap.
|
|
1544
|
+
const lines = [];
|
|
1545
|
+
if (headers) {
|
|
1546
|
+
const head = new Array(cols.length);
|
|
1547
|
+
for (let i = 0; i < cols.length; i++) {
|
|
1548
|
+
head[i] = _csvEscape(cols[i].header, delimiter, quote);
|
|
1549
|
+
}
|
|
1550
|
+
lines.push(head.join(delimiter));
|
|
1551
|
+
}
|
|
1552
|
+
for (let r = 0; r < rows.length; r++) {
|
|
1553
|
+
const row = rows[r];
|
|
1554
|
+
const cells = new Array(cols.length);
|
|
1555
|
+
for (let c = 0; c < cols.length; c++) {
|
|
1556
|
+
const col = cols[c];
|
|
1557
|
+
const raw = fmt ? fmt(row, col) : _cellValue(row, col);
|
|
1558
|
+
cells[c] = _csvEscape(raw, delimiter, quote);
|
|
1559
|
+
}
|
|
1560
|
+
lines.push(cells.join(delimiter));
|
|
1561
|
+
}
|
|
1562
|
+
const body = lines.join(newline);
|
|
1563
|
+
return bom ? "\uFEFF" + body : body;
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Export rows to a JSON string (or array, if `format: "array"`).
|
|
1568
|
+
*
|
|
1569
|
+
* @param {object} [opts]
|
|
1570
|
+
* @param {"visible"|"all"|"selected"|Array} [opts.rows="visible"]
|
|
1571
|
+
* @param {"visible"|"all"|Array<string>} [opts.columns="visible"]
|
|
1572
|
+
* When provided, output objects contain only the projected keys.
|
|
1573
|
+
* With "all" + no formatter, this is just `rows` unchanged
|
|
1574
|
+
* (the raw row objects), so we avoid an unnecessary allocation.
|
|
1575
|
+
* @param {number} [opts.indent=0]
|
|
1576
|
+
* JSON.stringify indent. 0 = compact (single line).
|
|
1577
|
+
* @param {"string"|"array"} [opts.format="string"]
|
|
1578
|
+
* "string" (default) returns the serialized JSON text.
|
|
1579
|
+
* "array" returns the projected array directly (skips
|
|
1580
|
+
* JSON.stringify). Useful for chaining into IndexedDB,
|
|
1581
|
+
* postMessage, structured clone, or further transforms.
|
|
1582
|
+
* @param {(row:any, col:ColumnState) => unknown} [opts.formatter]
|
|
1583
|
+
* Optional cell formatter, same shape as exportCsv.
|
|
1584
|
+
* @returns {string|Array<object>}
|
|
1585
|
+
*/
|
|
1586
|
+
function exportJson(opts) {
|
|
1587
|
+
opts = opts || {};
|
|
1588
|
+
const rows = _resolveRows(opts.rows);
|
|
1589
|
+
const colsOpt = opts.columns;
|
|
1590
|
+
const fmt = typeof opts.formatter === "function" ? opts.formatter : null;
|
|
1591
|
+
const indent = typeof opts.indent === "number" && opts.indent > 0 ? opts.indent : 0;
|
|
1592
|
+
const asArray = opts.format === "array";
|
|
1593
|
+
|
|
1594
|
+
let out;
|
|
1595
|
+
|
|
1596
|
+
// Fast path: when the consumer wants the raw row objects (no column
|
|
1597
|
+
// projection, no formatter) we can return the array as-is for
|
|
1598
|
+
// format:"array" or stringify it directly for format:"string".
|
|
1599
|
+
if (colsOpt === undefined && !fmt) {
|
|
1600
|
+
// Default behaviour: project to visible columns (matches CSV
|
|
1601
|
+
// default). If you really want the raw rows, pass columns: "all".
|
|
1602
|
+
// We still go through the projection path below.
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
if (colsOpt === "all" && !fmt) {
|
|
1606
|
+
// Raw rows -- safe because we don't mutate, the consumer owns
|
|
1607
|
+
// the lifetime of the strings.
|
|
1608
|
+
out = rows.slice();
|
|
1609
|
+
} else {
|
|
1610
|
+
const cols = _resolveColumns(colsOpt);
|
|
1611
|
+
out = new Array(rows.length);
|
|
1612
|
+
for (let r = 0; r < rows.length; r++) {
|
|
1613
|
+
const row = rows[r];
|
|
1614
|
+
const obj = {};
|
|
1615
|
+
for (let c = 0; c < cols.length; c++) {
|
|
1616
|
+
const col = cols[c];
|
|
1617
|
+
obj[col.key] = fmt ? fmt(row, col) : _cellValue(row, col);
|
|
1618
|
+
}
|
|
1619
|
+
out[r] = obj;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (asArray) return out;
|
|
1624
|
+
return indent > 0 ? JSON.stringify(out, null, indent) : JSON.stringify(out);
|
|
1625
|
+
}
|
|
1626
|
+
|
|
765
1627
|
return {
|
|
766
1628
|
// Static
|
|
767
1629
|
columns,
|
|
@@ -772,9 +1634,17 @@ export function createTable(config) {
|
|
|
772
1634
|
|
|
773
1635
|
// Reactive: data
|
|
774
1636
|
rowsGetter,
|
|
1637
|
+
filteredRows,
|
|
775
1638
|
visibleRows,
|
|
776
1639
|
rowCount,
|
|
777
1640
|
|
|
1641
|
+
// Reactive: grouping + aggregation (M3)
|
|
1642
|
+
groupBy,
|
|
1643
|
+
collapsedGroups,
|
|
1644
|
+
groupedRows,
|
|
1645
|
+
visibleEntries,
|
|
1646
|
+
entryCount,
|
|
1647
|
+
|
|
778
1648
|
// Reactive: columns
|
|
779
1649
|
columnOrder,
|
|
780
1650
|
visibleColumns,
|
|
@@ -785,14 +1655,17 @@ export function createTable(config) {
|
|
|
785
1655
|
leftOffsets,
|
|
786
1656
|
rightOffsets,
|
|
787
1657
|
|
|
788
|
-
// Reactive: sort
|
|
1658
|
+
// Reactive: sort / filters
|
|
789
1659
|
sortChain,
|
|
1660
|
+
columnFilters,
|
|
790
1661
|
|
|
791
|
-
// Reactive: focus / selection
|
|
1662
|
+
// Reactive: focus / selection / editing
|
|
792
1663
|
focusedCell,
|
|
793
1664
|
selection,
|
|
794
1665
|
selectionAnchor,
|
|
795
1666
|
selectedCount,
|
|
1667
|
+
editingCell,
|
|
1668
|
+
editingDraft,
|
|
796
1669
|
|
|
797
1670
|
// Methods: sort
|
|
798
1671
|
setSort, addSort, toggleSort, clearSort,
|
|
@@ -805,6 +1678,20 @@ export function createTable(config) {
|
|
|
805
1678
|
setColumnWidth, setColumnHidden, setColumnPin, setColumnFlex,
|
|
806
1679
|
setColumnOrder, moveColumn,
|
|
807
1680
|
|
|
1681
|
+
// Methods: filters
|
|
1682
|
+
setColumnFilter, clearColumnFilters,
|
|
1683
|
+
|
|
1684
|
+
// Methods: grouping (M3)
|
|
1685
|
+
setGroupBy, toggleGroup, expandGroup, collapseGroup,
|
|
1686
|
+
expandAllGroups, collapseAllGroups, isGroupCollapsed,
|
|
1687
|
+
groupAncestryAt,
|
|
1688
|
+
|
|
1689
|
+
// Methods: editing
|
|
1690
|
+
startEdit, commitEdit, cancelEdit, isEditing,
|
|
1691
|
+
|
|
1692
|
+
// Methods: export
|
|
1693
|
+
exportCsv, exportJson,
|
|
1694
|
+
|
|
808
1695
|
// Methods: focus
|
|
809
1696
|
moveFocus,
|
|
810
1697
|
|
|
@@ -913,7 +1800,82 @@ const DEFAULT_STYLES =
|
|
|
913
1800
|
// cell to a new stacking context (which box-shadow + position:relative
|
|
914
1801
|
// could do, causing pixel-shift during compositor layer creation), and
|
|
915
1802
|
// doesn't conflict with sticky positioning on pinned cells.
|
|
916
|
-
".lt-cell.is-focused{outline:2px solid #3b82f6;outline-offset:-2px}"
|
|
1803
|
+
".lt-cell.is-focused{outline:2px solid #3b82f6;outline-offset:-2px}" +
|
|
1804
|
+
// M2: editable cell affordance (subtle cursor hint on hover) + active
|
|
1805
|
+
// editing state (filled background + caret-line outline).
|
|
1806
|
+
".lt-cell[data-editable=\"true\"]{cursor:text}" +
|
|
1807
|
+
".lt-cell.is-editing{outline:2px solid #f59e0b;outline-offset:-2px;" +
|
|
1808
|
+
"background:#fff;overflow:visible;white-space:normal;" +
|
|
1809
|
+
"text-overflow:clip}" +
|
|
1810
|
+
// M2: filter row sits between header and viewport, mirrors --lt-cols.
|
|
1811
|
+
".lt-filter-row{position:sticky;top:32px;z-index:4;display:grid;" +
|
|
1812
|
+
"grid-template-columns:var(--lt-cols);background:#f1f5f9;" +
|
|
1813
|
+
"border-bottom:1px solid #e2e8f0;width:max-content;min-width:100%;" +
|
|
1814
|
+
"grid-auto-flow:column;grid-template-rows:auto}" +
|
|
1815
|
+
".lt-filter-cell{padding:4px 6px;box-sizing:border-box;" +
|
|
1816
|
+
"border-right:1px solid #f1f5f9;grid-row:1;background:inherit}" +
|
|
1817
|
+
".lt-filter-cell[data-pin=\"left\"]{position:sticky;z-index:5;background:#f1f5f9}" +
|
|
1818
|
+
".lt-filter-cell[data-pin=\"right\"]{position:sticky;z-index:5;background:#f1f5f9}" +
|
|
1819
|
+
".lt-filter-input{width:100%;padding:3px 6px;font:inherit;font-size:12px;" +
|
|
1820
|
+
"background:#fff;color:#0f172a;border:1px solid #cbd5e1;border-radius:3px;" +
|
|
1821
|
+
"outline:none;box-sizing:border-box}" +
|
|
1822
|
+
".lt-filter-input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #dbeafe}" +
|
|
1823
|
+
|
|
1824
|
+
// M3: group-header row -- solid background (never striped), bolder
|
|
1825
|
+
// typography, per-depth indent on the first-visible cell via padding.
|
|
1826
|
+
// The chevron + label are already textual so no icon font required.
|
|
1827
|
+
".lt-row-group-header{background:#eff6ff;font-weight:600;" +
|
|
1828
|
+
"border-bottom:1px solid #bfdbfe;cursor:pointer;" +
|
|
1829
|
+
// touch-action:none avoids the browser claiming pointermove for
|
|
1830
|
+
// native scroll before our toggle handler fires on tap.
|
|
1831
|
+
"touch-action:manipulation;user-select:none}" +
|
|
1832
|
+
".lt-row-group-header:hover{background:#dbeafe}" +
|
|
1833
|
+
".lt-row-group-header .lt-cell{background:inherit;font-weight:inherit;" +
|
|
1834
|
+
"color:#1e3a8a}" +
|
|
1835
|
+
// First cell (chevron + label + count) overflows visibly rather than
|
|
1836
|
+
// getting ellipsized -- the label is the group's identity and the user
|
|
1837
|
+
// needs to read it, even when the first column is narrow (id, checkbox).
|
|
1838
|
+
// Non-first cells keep their normal clipping.
|
|
1839
|
+
".lt-row-group-header .lt-cell:first-child{overflow:visible;" +
|
|
1840
|
+
"white-space:nowrap;text-overflow:clip;z-index:1;position:relative}" +
|
|
1841
|
+
// Indent the first cell per depth. CSS attribute selectors keep the
|
|
1842
|
+
// effect purely in CSS -- no per-slot inline style writes.
|
|
1843
|
+
".lt-row-group-header[data-depth=\"0\"] .lt-cell:first-child{padding-left:12px}" +
|
|
1844
|
+
".lt-row-group-header[data-depth=\"1\"] .lt-cell:first-child{padding-left:28px}" +
|
|
1845
|
+
".lt-row-group-header[data-depth=\"2\"] .lt-cell:first-child{padding-left:44px}" +
|
|
1846
|
+
".lt-row-group-header[data-depth=\"3\"] .lt-cell:first-child{padding-left:60px}" +
|
|
1847
|
+
".lt-row-group-header[data-depth=\"4\"] .lt-cell:first-child{padding-left:76px}" +
|
|
1848
|
+
// Collapsed state: dim the row slightly to hint the group is folded.
|
|
1849
|
+
".lt-row-group-header[data-collapsed=\"true\"]{opacity:0.85}" +
|
|
1850
|
+
// Aggregate cells (non-first) get a slightly muted color so the
|
|
1851
|
+
// group's key value (in first cell) reads as the identity.
|
|
1852
|
+
".lt-row-group-header .lt-cell:not(:first-child){color:#3730a3;" +
|
|
1853
|
+
"text-align:right;font-variant-numeric:tabular-nums}" +
|
|
1854
|
+
|
|
1855
|
+
// M3: grand-total row -- pinned appearance (thick top border, sturdy
|
|
1856
|
+
// typography). Sits at the tail of visibleEntries when enabled.
|
|
1857
|
+
".lt-row-grand-total{background:#f0f9ff;font-weight:700;" +
|
|
1858
|
+
"border-top:2px solid #7dd3fc;border-bottom:1px solid #7dd3fc;" +
|
|
1859
|
+
"color:#0c4a6e}" +
|
|
1860
|
+
".lt-row-grand-total .lt-cell{background:inherit;font-weight:inherit;" +
|
|
1861
|
+
"color:inherit}" +
|
|
1862
|
+
// First cell (the "Total (N)" label) overflows visibly rather than
|
|
1863
|
+
// getting ellipsized when the first column is narrow. Same rationale
|
|
1864
|
+
// as `.lt-row-group-header .lt-cell:first-child`.
|
|
1865
|
+
".lt-row-grand-total .lt-cell:first-child{overflow:visible;" +
|
|
1866
|
+
"white-space:nowrap;text-overflow:clip;z-index:1;position:relative}" +
|
|
1867
|
+
".lt-row-grand-total .lt-cell:not(:first-child){text-align:right;" +
|
|
1868
|
+
"font-variant-numeric:tabular-nums}" +
|
|
1869
|
+
|
|
1870
|
+
// M3: sticky overlays -- containers are zero-height so they don't
|
|
1871
|
+
// reserve scroll space. Their absolute-positioned rows sit on top of
|
|
1872
|
+
// the pool via z-index. Subtle bottom-shadow on sticky group headers
|
|
1873
|
+
// and top-shadow on the sticky footer help them float visually above
|
|
1874
|
+
// the data underneath.
|
|
1875
|
+
".lt-sticky-groups{}" +
|
|
1876
|
+
".lt-sticky-group{box-shadow:0 1px 2px rgba(15,23,42,0.08)}" +
|
|
1877
|
+
".lt-sticky-grand-total{}" +
|
|
1878
|
+
".lt-sticky-grand-total-row{box-shadow:0 -1px 2px rgba(15,23,42,0.08)}";
|
|
917
1879
|
|
|
918
1880
|
let _stylesInjected = new WeakSet();
|
|
919
1881
|
function injectStyles(doc) {
|
|
@@ -991,7 +1953,13 @@ export function mountTable(host, table, options) {
|
|
|
991
1953
|
sortChain, focusedCell, selection,
|
|
992
1954
|
getRowId, cellId,
|
|
993
1955
|
toggleSort, selectRow, isSelected, moveFocus,
|
|
994
|
-
setColumnWidth, moveColumn
|
|
1956
|
+
setColumnWidth, moveColumn,
|
|
1957
|
+
// M2 surface
|
|
1958
|
+
columnFilters, setColumnFilter,
|
|
1959
|
+
editingCell, editingDraft, startEdit, commitEdit, cancelEdit,
|
|
1960
|
+
// M3 surface
|
|
1961
|
+
visibleEntries, entryCount, groupBy, collapsedGroups, groupedRows,
|
|
1962
|
+
toggleGroup, groupAncestryAt
|
|
995
1963
|
} = table;
|
|
996
1964
|
|
|
997
1965
|
const { scope, dispose: disposeScope } = createScope();
|
|
@@ -1052,6 +2020,93 @@ export function mountTable(host, table, options) {
|
|
|
1052
2020
|
}
|
|
1053
2021
|
root.appendChild(header);
|
|
1054
2022
|
|
|
2023
|
+
// ----- Filter row (M2) --------------------------------------------------
|
|
2024
|
+
// Only mounted if at least one declared column has filterable: true.
|
|
2025
|
+
// Sits between the header and the viewport in the DOM, uses the same
|
|
2026
|
+
// CSS Grid template (via --lt-cols inherited from root), and recycles
|
|
2027
|
+
// never -- one input per filterable column, hidden when the column is
|
|
2028
|
+
// hidden.
|
|
2029
|
+
const filterCells = new Map(); // key -> input element
|
|
2030
|
+
const hasAnyFilterable = columns.some((c) => c.filterable);
|
|
2031
|
+
let filterRow = null;
|
|
2032
|
+
if (hasAnyFilterable) {
|
|
2033
|
+
filterRow = doc.createElement("div");
|
|
2034
|
+
filterRow.className = "lt-filter-row";
|
|
2035
|
+
filterRow.setAttribute("role", "row");
|
|
2036
|
+
filterRow.setAttribute("aria-rowindex", "2");
|
|
2037
|
+
|
|
2038
|
+
for (const col of columns) {
|
|
2039
|
+
const cell = doc.createElement("div");
|
|
2040
|
+
cell.className = "lt-filter-cell";
|
|
2041
|
+
cell.setAttribute("data-key", col.key);
|
|
2042
|
+
cell.setAttribute("data-pin", "none");
|
|
2043
|
+
|
|
2044
|
+
if (col.filterable) {
|
|
2045
|
+
const input = doc.createElement("input");
|
|
2046
|
+
input.type = "text";
|
|
2047
|
+
input.className = "lt-filter-input";
|
|
2048
|
+
input.setAttribute("aria-label", "Filter " + col.header);
|
|
2049
|
+
if (col.filterPlaceholder) input.placeholder = col.filterPlaceholder;
|
|
2050
|
+
else input.placeholder = "Filter…";
|
|
2051
|
+
|
|
2052
|
+
// Two-way binding. Outer source of truth is columnFilters; we
|
|
2053
|
+
// read it on every change and write into the input only if the
|
|
2054
|
+
// values diverged (so user typing doesn't get reset by their
|
|
2055
|
+
// own write echoing back). Likewise we write into the signal
|
|
2056
|
+
// from `input` events only when divergent.
|
|
2057
|
+
scope.effect(() => {
|
|
2058
|
+
const cur = columnFilters().get(col.key) || "";
|
|
2059
|
+
if (input.value !== cur) input.value = cur;
|
|
2060
|
+
});
|
|
2061
|
+
scope.on(input, "input", () => {
|
|
2062
|
+
setColumnFilter(col.key, input.value);
|
|
2063
|
+
});
|
|
2064
|
+
// Escape clears just this column's filter.
|
|
2065
|
+
scope.on(input, "keydown", (ev) => {
|
|
2066
|
+
if (ev.key === "Escape") {
|
|
2067
|
+
ev.preventDefault();
|
|
2068
|
+
setColumnFilter(col.key, "");
|
|
2069
|
+
}
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
cell.appendChild(input);
|
|
2073
|
+
filterCells.set(col.key, input);
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// Mirror grid placement, hide, and pin offsets from the column
|
|
2077
|
+
// state -- same logic as header cells. We reuse the column's
|
|
2078
|
+
// reactive placement so a hidden column drops its filter cell
|
|
2079
|
+
// too, and pinned columns keep their filter input sticky.
|
|
2080
|
+
scope.effect(() => {
|
|
2081
|
+
const placement = colPlacement().get(col.key);
|
|
2082
|
+
if (placement == null) {
|
|
2083
|
+
cell.style.display = "none";
|
|
2084
|
+
} else {
|
|
2085
|
+
cell.style.display = "";
|
|
2086
|
+
cell.style.gridColumn = placement + " / span 1";
|
|
2087
|
+
}
|
|
2088
|
+
});
|
|
2089
|
+
scope.effect(() => {
|
|
2090
|
+
const pinSide = col.pin();
|
|
2091
|
+
cell.setAttribute("data-pin", pinSide);
|
|
2092
|
+
if (pinSide === "left") {
|
|
2093
|
+
cell.style.left = (leftOffsets().get(col.key) || 0) + "px";
|
|
2094
|
+
cell.style.right = "";
|
|
2095
|
+
} else if (pinSide === "right") {
|
|
2096
|
+
cell.style.right = (rightOffsets().get(col.key) || 0) + "px";
|
|
2097
|
+
cell.style.left = "";
|
|
2098
|
+
} else {
|
|
2099
|
+
cell.style.left = "";
|
|
2100
|
+
cell.style.right = "";
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
filterRow.appendChild(cell);
|
|
2105
|
+
}
|
|
2106
|
+
|
|
2107
|
+
root.appendChild(filterRow);
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1055
2110
|
// Per-header reactive bindings: gridColumn, display, sticky pin, aria-sort.
|
|
1056
2111
|
for (const col of columns) {
|
|
1057
2112
|
const hc = headerCells.get(col.key);
|
|
@@ -1156,7 +2211,11 @@ export function mountTable(host, table, options) {
|
|
|
1156
2211
|
overscan
|
|
1157
2212
|
});
|
|
1158
2213
|
|
|
1159
|
-
|
|
2214
|
+
// The virtual axis reserves scroll height for EVERY visible entry --
|
|
2215
|
+
// data rows AND group-header + grand-total rows all take rowHeight.
|
|
2216
|
+
// rowCount (data-only) is exposed on the core for consumer stats but
|
|
2217
|
+
// never drives the axis, or headers would overlap the last data row.
|
|
2218
|
+
scope.effect(() => { axis.setCount(entryCount()); });
|
|
1160
2219
|
scope.effect(() => { inner.style.height = axis.totalSize() + "px"; });
|
|
1161
2220
|
|
|
1162
2221
|
// ----- Slot pool --------------------------------------------------------
|
|
@@ -1166,6 +2225,37 @@ export function mountTable(host, table, options) {
|
|
|
1166
2225
|
|
|
1167
2226
|
const slots = [];
|
|
1168
2227
|
|
|
2228
|
+
// Reactive: which column is currently first-in-display-order. Group
|
|
2229
|
+
// headers put their chevron + label + count in this column's cell;
|
|
2230
|
+
// the aggregate values go into the rest. Recomputes on show/hide/
|
|
2231
|
+
// reorder -- so a hidden first column bumps the label into the next.
|
|
2232
|
+
const firstVisibleColKey = scope.computed(() => {
|
|
2233
|
+
const cols = visibleColumns();
|
|
2234
|
+
return cols.length > 0 ? cols[0].key : null;
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
// Chevron glyphs -- ASCII-adjacent Unicode that renders reliably
|
|
2238
|
+
// across system fonts without a webfont dependency.
|
|
2239
|
+
const CHEVRON_EXPANDED = "\u25BC"; // ▼
|
|
2240
|
+
const CHEVRON_COLLAPSED = "\u25B6"; // ▶
|
|
2241
|
+
|
|
2242
|
+
// Format an aggregate value for display. Uses the column's
|
|
2243
|
+
// `aggregateFormat` if provided, otherwise falls back to String().
|
|
2244
|
+
// Null aggregates render as empty (they mean "no values to aggregate").
|
|
2245
|
+
function _formatAggregate(entry, col) {
|
|
2246
|
+
if (!entry.aggregates) return "";
|
|
2247
|
+
const v = entry.aggregates.get(col.key);
|
|
2248
|
+
if (v == null) return "";
|
|
2249
|
+
if (col.aggregateFormat) {
|
|
2250
|
+
try { return col.aggregateFormat(v, col, entry.count); }
|
|
2251
|
+
catch (err) {
|
|
2252
|
+
try { console.error("lite-table: aggregateFormat threw:", err); } catch (_) {}
|
|
2253
|
+
return String(v);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
return String(v);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
1169
2259
|
function buildSlot(poolIdx) {
|
|
1170
2260
|
const rowEl = doc.createElement("div");
|
|
1171
2261
|
rowEl.className = "lt-row";
|
|
@@ -1173,16 +2263,29 @@ export function mountTable(host, table, options) {
|
|
|
1173
2263
|
|
|
1174
2264
|
const slotIndex = scope.computed(() => axis.start() + poolIdx);
|
|
1175
2265
|
|
|
2266
|
+
// The one source of truth for what this pool slot renders. Reads
|
|
2267
|
+
// visibleEntries() so it dispatches on entry.type (data /
|
|
2268
|
+
// group-header / grand-total). All row-level and cell-level
|
|
2269
|
+
// effects below read slotEntry rather than visibleRows/visibleEntries
|
|
2270
|
+
// directly -- keeps every effect down to a single-signal read.
|
|
2271
|
+
const slotEntry = scope.computed(() => {
|
|
2272
|
+
const es = visibleEntries();
|
|
2273
|
+
const i = slotIndex();
|
|
2274
|
+
if (i < 0 || i >= es.length) return null;
|
|
2275
|
+
return es[i];
|
|
2276
|
+
});
|
|
2277
|
+
|
|
1176
2278
|
// Position (translateY) -- single transform write per boundary cross.
|
|
1177
2279
|
scope.effect(() => {
|
|
1178
2280
|
const i = slotIndex();
|
|
1179
2281
|
rowEl.style.transform = "translateY(" + (i * rowHeight) + "px)";
|
|
1180
2282
|
});
|
|
1181
2283
|
|
|
1182
|
-
// Visibility & aria-rowindex
|
|
2284
|
+
// Visibility & aria-rowindex driven by entryCount so out-of-bounds
|
|
2285
|
+
// slots hide immediately (e.g. after collapsing a big group).
|
|
1183
2286
|
scope.effect(() => {
|
|
1184
2287
|
const i = slotIndex();
|
|
1185
|
-
const n =
|
|
2288
|
+
const n = entryCount();
|
|
1186
2289
|
if (i < 0 || i >= n) {
|
|
1187
2290
|
rowEl.style.display = "none";
|
|
1188
2291
|
rowEl.removeAttribute("aria-rowindex");
|
|
@@ -1192,28 +2295,58 @@ export function mountTable(host, table, options) {
|
|
|
1192
2295
|
}
|
|
1193
2296
|
});
|
|
1194
2297
|
|
|
1195
|
-
//
|
|
2298
|
+
// Row-type discriminator: group-header + grand-total get their own
|
|
2299
|
+
// classes and data-attributes. Data rows keep the base .lt-row plus
|
|
2300
|
+
// alt striping. `data-depth` on headers lets the stylesheet indent
|
|
2301
|
+
// per depth without JS style writes.
|
|
2302
|
+
scope.effect(() => {
|
|
2303
|
+
const entry = slotEntry();
|
|
2304
|
+
rowEl.classList.remove("lt-row-group-header", "lt-row-grand-total");
|
|
2305
|
+
if (entry === null) {
|
|
2306
|
+
rowEl.removeAttribute("data-depth");
|
|
2307
|
+
rowEl.removeAttribute("data-collapsed");
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
if (entry.type === "group-header") {
|
|
2311
|
+
rowEl.classList.add("lt-row-group-header");
|
|
2312
|
+
rowEl.setAttribute("data-depth", String(entry.depth));
|
|
2313
|
+
rowEl.setAttribute("data-collapsed", entry.isCollapsed ? "true" : "false");
|
|
2314
|
+
} else if (entry.type === "grand-total") {
|
|
2315
|
+
rowEl.classList.add("lt-row-grand-total");
|
|
2316
|
+
rowEl.removeAttribute("data-depth");
|
|
2317
|
+
rowEl.removeAttribute("data-collapsed");
|
|
2318
|
+
} else {
|
|
2319
|
+
rowEl.removeAttribute("data-depth");
|
|
2320
|
+
rowEl.removeAttribute("data-collapsed");
|
|
2321
|
+
}
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
// Alt striping -- data rows only, tied to slotIndex parity of the
|
|
2325
|
+
// data row's ORDINAL POSITION would be ideal, but computing that
|
|
2326
|
+
// requires another walk. Using slotIndex parity (entry position)
|
|
2327
|
+
// gives visually consistent striping across data rows even when
|
|
2328
|
+
// interrupted by group headers -- it just resets at each header.
|
|
1196
2329
|
scope.effect(() => {
|
|
2330
|
+
const entry = slotEntry();
|
|
1197
2331
|
const i = slotIndex();
|
|
1198
|
-
|
|
2332
|
+
const isDataAlt = entry !== null && entry.type === "data" && (i & 1);
|
|
2333
|
+
if (isDataAlt) rowEl.classList.add("lt-row-alt");
|
|
1199
2334
|
else rowEl.classList.remove("lt-row-alt");
|
|
1200
2335
|
});
|
|
1201
2336
|
|
|
1202
|
-
// Selection highlight
|
|
1203
|
-
//
|
|
2337
|
+
// Selection highlight -- data rows only. Group headers and grand
|
|
2338
|
+
// total never appear "selected"; clicking them toggles the group
|
|
2339
|
+
// or does nothing rather than adding to selection.
|
|
1204
2340
|
scope.onCleanup(bindClass(rowEl, "is-selected", () => {
|
|
1205
|
-
const
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
return isSelected(getRowId(rs[i]));
|
|
2341
|
+
const entry = slotEntry();
|
|
2342
|
+
if (entry === null || entry.type !== "data") return false;
|
|
2343
|
+
return isSelected(getRowId(entry.row));
|
|
1209
2344
|
}));
|
|
1210
2345
|
|
|
1211
|
-
// aria-selected on the row.
|
|
1212
2346
|
scope.onCleanup(bindAttr(rowEl, "aria-selected", () => {
|
|
1213
|
-
const
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
return isSelected(getRowId(rs[i])) ? "true" : "false";
|
|
2347
|
+
const entry = slotEntry();
|
|
2348
|
+
if (entry === null || entry.type !== "data") return null;
|
|
2349
|
+
return isSelected(getRowId(entry.row)) ? "true" : "false";
|
|
1217
2350
|
}));
|
|
1218
2351
|
|
|
1219
2352
|
// ----- Cells (one per declared column, in DOM config order) ---------
|
|
@@ -1223,8 +2356,6 @@ export function mountTable(host, table, options) {
|
|
|
1223
2356
|
cellEl.className = "lt-cell";
|
|
1224
2357
|
cellEl.setAttribute("role", "gridcell");
|
|
1225
2358
|
cellEl.setAttribute("aria-colindex", String(c + 1));
|
|
1226
|
-
// Static -- never changes for this cell. Read by the delegated
|
|
1227
|
-
// pointerdown handler on root to identify which column was tapped.
|
|
1228
2359
|
cellEl.setAttribute("data-key", col.key);
|
|
1229
2360
|
|
|
1230
2361
|
// Reactive grid placement / hide.
|
|
@@ -1254,42 +2385,161 @@ export function mountTable(host, table, options) {
|
|
|
1254
2385
|
}
|
|
1255
2386
|
});
|
|
1256
2387
|
|
|
1257
|
-
// Reactive text.
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
2388
|
+
// Reactive text -- dispatches on entry.type. Manual effect (not
|
|
2389
|
+
// bindText) so the editing gate can skip the write when this
|
|
2390
|
+
// cell is the active edit target.
|
|
2391
|
+
scope.effect(() => {
|
|
2392
|
+
const entry = slotEntry();
|
|
2393
|
+
if (entry === null) {
|
|
2394
|
+
if (cellEl.textContent !== "") cellEl.textContent = "";
|
|
2395
|
+
return;
|
|
2396
|
+
}
|
|
1265
2397
|
|
|
1266
|
-
|
|
2398
|
+
if (entry.type === "data") {
|
|
2399
|
+
// Editing gate: suspend text writes while the user is
|
|
2400
|
+
// typing in this cell -- the contenteditable IS the
|
|
2401
|
+
// source of truth until commitEdit runs. The effect
|
|
2402
|
+
// still tracks editingCell so it resumes cleanly.
|
|
2403
|
+
if (col.editable) {
|
|
2404
|
+
const e = editingCell();
|
|
2405
|
+
if (e !== null && e.rowId === getRowId(entry.row) && e.columnKey === col.key) {
|
|
2406
|
+
return;
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
const v = readCell(col, entry.row);
|
|
2410
|
+
const text = v == null ? "" : String(v);
|
|
2411
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
if (entry.type === "group-header") {
|
|
2416
|
+
// First visible column holds the chevron + label.
|
|
2417
|
+
// (Reads firstVisibleColKey reactively -- column
|
|
2418
|
+
// hide/reorder repaints the affected cells.)
|
|
2419
|
+
if (firstVisibleColKey() === col.key) {
|
|
2420
|
+
const chevron = entry.isCollapsed ? CHEVRON_COLLAPSED : CHEVRON_EXPANDED;
|
|
2421
|
+
const label = entry.value == null ? "(none)" : String(entry.value);
|
|
2422
|
+
const text = chevron + " " + label + " (" + entry.count + ")";
|
|
2423
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2424
|
+
} else {
|
|
2425
|
+
const text = _formatAggregate(entry, col);
|
|
2426
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2427
|
+
}
|
|
2428
|
+
return;
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
if (entry.type === "grand-total") {
|
|
2432
|
+
if (firstVisibleColKey() === col.key) {
|
|
2433
|
+
const text = "Total (" + entry.count + ")";
|
|
2434
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2435
|
+
} else {
|
|
2436
|
+
const text = _formatAggregate(entry, col);
|
|
2437
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2438
|
+
}
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
// Reactive id (the aria-activedescendant target). Only data
|
|
2444
|
+
// cells get an id -- headers / totals have no rowId.
|
|
1267
2445
|
scope.onCleanup(bindAttr(cellEl, "id", () => {
|
|
1268
|
-
const
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
const row = rs[i];
|
|
1272
|
-
if (row == null) return null;
|
|
1273
|
-
return cellId(getRowId(row), col.key);
|
|
2446
|
+
const entry = slotEntry();
|
|
2447
|
+
if (entry === null || entry.type !== "data") return null;
|
|
2448
|
+
return cellId(getRowId(entry.row), col.key);
|
|
1274
2449
|
}));
|
|
1275
2450
|
|
|
1276
|
-
// Focus indicator.
|
|
1277
|
-
//
|
|
1278
|
-
// cell flips one class; only the 1-2 cells that gain/lose focus
|
|
1279
|
-
// produce classList writes.
|
|
2451
|
+
// Focus indicator. Same restriction -- only data cells can be
|
|
2452
|
+
// focused via the keyboard grid.
|
|
1280
2453
|
scope.onCleanup(bindClass(cellEl, "is-focused", () => {
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
if (i < 0 || i >= rs.length) return false;
|
|
1284
|
-
const row = rs[i];
|
|
1285
|
-
if (row == null) return false;
|
|
2454
|
+
const entry = slotEntry();
|
|
2455
|
+
if (entry === null || entry.type !== "data") return false;
|
|
1286
2456
|
const f = focusedCell();
|
|
1287
2457
|
if (!f) return false;
|
|
1288
|
-
return getRowId(row) === f.rowId && col.key === f.columnKey;
|
|
2458
|
+
return getRowId(entry.row) === f.rowId && col.key === f.columnKey;
|
|
1289
2459
|
}));
|
|
1290
2460
|
|
|
1291
|
-
//
|
|
1292
|
-
//
|
|
2461
|
+
// Editable machinery is gated on data rows: group-header cells
|
|
2462
|
+
// are visually plain even for editable columns.
|
|
2463
|
+
if (col.editable) {
|
|
2464
|
+
cellEl.setAttribute("data-editable", "true");
|
|
2465
|
+
|
|
2466
|
+
// Editing state painted as data + class + contenteditable.
|
|
2467
|
+
scope.effect(() => {
|
|
2468
|
+
const entry = slotEntry();
|
|
2469
|
+
if (entry === null || entry.type !== "data") {
|
|
2470
|
+
cellEl.removeAttribute("contenteditable");
|
|
2471
|
+
cellEl.classList.remove("is-editing");
|
|
2472
|
+
return;
|
|
2473
|
+
}
|
|
2474
|
+
const row = entry.row;
|
|
2475
|
+
const e = editingCell();
|
|
2476
|
+
const editingThis = e !== null && e.rowId === getRowId(row) && e.columnKey === col.key;
|
|
2477
|
+
if (editingThis) {
|
|
2478
|
+
if (cellEl.getAttribute("contenteditable") !== "true") {
|
|
2479
|
+
cellEl.setAttribute("contenteditable", "true");
|
|
2480
|
+
const seed = editingDraft.peek();
|
|
2481
|
+
if (cellEl.textContent !== seed) cellEl.textContent = seed;
|
|
2482
|
+
queueMicrotask(() => {
|
|
2483
|
+
if (cellEl.getAttribute("contenteditable") === "true") {
|
|
2484
|
+
cellEl.focus();
|
|
2485
|
+
const sel = doc.getSelection ? doc.getSelection() : null;
|
|
2486
|
+
if (sel) {
|
|
2487
|
+
const range = doc.createRange();
|
|
2488
|
+
range.selectNodeContents(cellEl);
|
|
2489
|
+
sel.removeAllRanges();
|
|
2490
|
+
sel.addRange(range);
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
});
|
|
2494
|
+
}
|
|
2495
|
+
cellEl.classList.add("is-editing");
|
|
2496
|
+
} else {
|
|
2497
|
+
if (cellEl.hasAttribute("contenteditable")) {
|
|
2498
|
+
cellEl.removeAttribute("contenteditable");
|
|
2499
|
+
}
|
|
2500
|
+
cellEl.classList.remove("is-editing");
|
|
2501
|
+
}
|
|
2502
|
+
});
|
|
2503
|
+
|
|
2504
|
+
scope.on(cellEl, "input", () => {
|
|
2505
|
+
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
2506
|
+
editingDraft.set(cellEl.textContent || "");
|
|
2507
|
+
});
|
|
2508
|
+
|
|
2509
|
+
scope.on(cellEl, "keydown", (ev) => {
|
|
2510
|
+
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
2511
|
+
if (ev.key === "Escape") {
|
|
2512
|
+
ev.preventDefault();
|
|
2513
|
+
ev.stopPropagation();
|
|
2514
|
+
cancelEdit();
|
|
2515
|
+
root.focus();
|
|
2516
|
+
} else if (ev.key === "Enter" && !ev.shiftKey) {
|
|
2517
|
+
ev.preventDefault();
|
|
2518
|
+
ev.stopPropagation();
|
|
2519
|
+
commitEdit();
|
|
2520
|
+
root.focus();
|
|
2521
|
+
moveFocus("down");
|
|
2522
|
+
} else if (ev.key === "Tab") {
|
|
2523
|
+
ev.preventDefault();
|
|
2524
|
+
ev.stopPropagation();
|
|
2525
|
+
commitEdit();
|
|
2526
|
+
root.focus();
|
|
2527
|
+
moveFocus(ev.shiftKey ? "left" : "right");
|
|
2528
|
+
}
|
|
2529
|
+
});
|
|
2530
|
+
|
|
2531
|
+
scope.on(cellEl, "blur", () => {
|
|
2532
|
+
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
2533
|
+
commitEdit();
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
scope.on(cellEl, "dblclick", (ev) => {
|
|
2537
|
+
const entry = slotEntry.peek();
|
|
2538
|
+
if (entry === null || entry.type !== "data") return;
|
|
2539
|
+
ev.preventDefault();
|
|
2540
|
+
startEdit(getRowId(entry.row), col.key);
|
|
2541
|
+
});
|
|
2542
|
+
}
|
|
1293
2543
|
|
|
1294
2544
|
rowEl.appendChild(cellEl);
|
|
1295
2545
|
}
|
|
@@ -1305,6 +2555,254 @@ export function mountTable(host, table, options) {
|
|
|
1305
2555
|
while (slots.length < want) slots.push(buildSlot(slots.length));
|
|
1306
2556
|
});
|
|
1307
2557
|
|
|
2558
|
+
// ----- Sticky group-header + grand-total overlays -----------------------
|
|
2559
|
+
// Both are `position: sticky` zero-height containers that live as
|
|
2560
|
+
// DIRECT CHILDREN OF `.lt-viewport`:
|
|
2561
|
+
// .lt-sticky-groups -- inserted BEFORE .lt-inner
|
|
2562
|
+
// .lt-sticky-grand-total -- appended AFTER .lt-inner
|
|
2563
|
+
// This matters because `position: sticky` is relative to the element's
|
|
2564
|
+
// natural flow position. Putting them inside .lt-inner (where the pool
|
|
2565
|
+
// slots live absolute) would give both containers a natural position
|
|
2566
|
+
// of 0 -- fine for `top: 32`, WRONG for `bottom: 0` (that only kicks
|
|
2567
|
+
// in when the natural position is BELOW viewport bottom, so a footer
|
|
2568
|
+
// at flow-top just... sits at the top). Placing them around .lt-inner
|
|
2569
|
+
// -- whose height reflects the scrollable content -- gives each the
|
|
2570
|
+
// natural position that matches the sticky edge it's aiming for.
|
|
2571
|
+
//
|
|
2572
|
+
// Design invariants:
|
|
2573
|
+
// - Sticky headers show the ANCESTORS of visibleEntries[axis.start()].
|
|
2574
|
+
// If the top-visible entry is itself a group header, its own row is
|
|
2575
|
+
// drawn by the pool at translateY(start * rowHeight); sticky shows
|
|
2576
|
+
// only strictly-shallower ancestors, which is [] for a depth-0
|
|
2577
|
+
// header. No duplication.
|
|
2578
|
+
// - Sticky grand-total mirrors the last entry's aggregates. When the
|
|
2579
|
+
// inline grand-total row is scrolled into view, the sticky row sits
|
|
2580
|
+
// on top of it -- same content, no visible difference.
|
|
2581
|
+
// - Both are hidden when their prerequisites aren't met (no grouping,
|
|
2582
|
+
// no grand total configured). Neither injects DOM or effects into
|
|
2583
|
+
// the ungrouped fast path beyond the two guarding effects.
|
|
2584
|
+
|
|
2585
|
+
// Local lookup so sticky effects don't have to walk `columns` linearly.
|
|
2586
|
+
const _mountColumnsByKey = new Map(columns.map(c => [c.key, c]));
|
|
2587
|
+
|
|
2588
|
+
// Sticky group-headers: BEFORE .lt-inner in the viewport's flow, sticks
|
|
2589
|
+
// at viewport top:<headerHeight> so it clears the sticky column header
|
|
2590
|
+
// (which itself sits at top:0). We use rowHeight for the header height
|
|
2591
|
+
// since that matches the padding+content of `.lt-header-cell` -- if a
|
|
2592
|
+
// consumer restyles the header taller, they'll want a bigger offset.
|
|
2593
|
+
const stickyGroupsEl = doc.createElement("div");
|
|
2594
|
+
stickyGroupsEl.className = "lt-sticky-groups";
|
|
2595
|
+
stickyGroupsEl.setAttribute("aria-hidden", "true");
|
|
2596
|
+
stickyGroupsEl.style.cssText =
|
|
2597
|
+
"position:sticky;top:" + rowHeight + "px;" +
|
|
2598
|
+
"left:0;right:0;height:0;z-index:2;pointer-events:none;";
|
|
2599
|
+
viewport.insertBefore(stickyGroupsEl, inner);
|
|
2600
|
+
|
|
2601
|
+
const _stickyRows = [];
|
|
2602
|
+
function _buildStickyRow(depth) {
|
|
2603
|
+
const rowEl = doc.createElement("div");
|
|
2604
|
+
// NOTE: no `.lt-row` on sticky rows -- the base class is used by
|
|
2605
|
+
// the 1.1.0 test suite (and by consumers) to count pool slots via
|
|
2606
|
+
// `querySelectorAll(".lt-row")`. Sticky rows carry only their
|
|
2607
|
+
// discriminator classes; the grid layout that `.lt-row` provides
|
|
2608
|
+
// is inlined below (display:grid + grid-template-columns).
|
|
2609
|
+
rowEl.className = "lt-row-group-header lt-sticky-group";
|
|
2610
|
+
rowEl.setAttribute("data-depth", String(depth));
|
|
2611
|
+
rowEl.style.cssText =
|
|
2612
|
+
"position:absolute;left:0;right:0;" +
|
|
2613
|
+
"top:" + (depth * rowHeight) + "px;" +
|
|
2614
|
+
"height:" + rowHeight + "px;" +
|
|
2615
|
+
"display:grid;grid-template-columns:var(--lt-cols);" +
|
|
2616
|
+
"width:max-content;min-width:100%;" +
|
|
2617
|
+
"pointer-events:auto;";
|
|
2618
|
+
const cells = new Map();
|
|
2619
|
+
for (let c = 0; c < columns.length; c++) {
|
|
2620
|
+
const col = columns[c];
|
|
2621
|
+
const cellEl = doc.createElement("div");
|
|
2622
|
+
cellEl.className = "lt-cell";
|
|
2623
|
+
cellEl.setAttribute("data-key", col.key);
|
|
2624
|
+
// Reactive per-cell grid placement + pin, matching pool cells so
|
|
2625
|
+
// sticky rows track column reorder / hide / pin the same way.
|
|
2626
|
+
scope.effect(() => {
|
|
2627
|
+
const placement = colPlacement().get(col.key);
|
|
2628
|
+
if (placement == null) {
|
|
2629
|
+
cellEl.style.display = "none";
|
|
2630
|
+
} else {
|
|
2631
|
+
cellEl.style.display = "";
|
|
2632
|
+
cellEl.style.gridColumn = placement + " / span 1";
|
|
2633
|
+
}
|
|
2634
|
+
});
|
|
2635
|
+
scope.effect(() => {
|
|
2636
|
+
const pinSide = col.pin();
|
|
2637
|
+
cellEl.setAttribute("data-pin", pinSide);
|
|
2638
|
+
if (pinSide === "left") {
|
|
2639
|
+
cellEl.style.left = (leftOffsets().get(col.key) || 0) + "px";
|
|
2640
|
+
cellEl.style.right = "";
|
|
2641
|
+
} else if (pinSide === "right") {
|
|
2642
|
+
cellEl.style.right = (rightOffsets().get(col.key) || 0) + "px";
|
|
2643
|
+
cellEl.style.left = "";
|
|
2644
|
+
} else {
|
|
2645
|
+
cellEl.style.left = "";
|
|
2646
|
+
cellEl.style.right = "";
|
|
2647
|
+
}
|
|
2648
|
+
});
|
|
2649
|
+
rowEl.appendChild(cellEl);
|
|
2650
|
+
cells.set(col.key, cellEl);
|
|
2651
|
+
}
|
|
2652
|
+
// Toggle the group by clicking anywhere on the sticky row. Reads the
|
|
2653
|
+
// closure-captured info.currentEntry so we always toggle the group
|
|
2654
|
+
// the row is currently showing, not the one it was built for.
|
|
2655
|
+
const info = { row: rowEl, cells, currentEntry: null };
|
|
2656
|
+
scope.on(rowEl, "pointerdown", (ev) => {
|
|
2657
|
+
if (!ev.isPrimary || ev.button !== 0) return;
|
|
2658
|
+
if (info.currentEntry) toggleGroup(info.currentEntry.path);
|
|
2659
|
+
});
|
|
2660
|
+
return info;
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
// Reactive sync: watch axis.start() + visibleEntries + column changes.
|
|
2664
|
+
// Emits/hides sticky rows to match `groupAncestryAt(axis.start())`.
|
|
2665
|
+
scope.effect(() => {
|
|
2666
|
+
// Ungrouped fast path: hide everything and skip.
|
|
2667
|
+
if (groupBy().length === 0) {
|
|
2668
|
+
stickyGroupsEl.style.display = "none";
|
|
2669
|
+
for (let i = 0; i < _stickyRows.length; i++) {
|
|
2670
|
+
_stickyRows[i].row.style.display = "none";
|
|
2671
|
+
_stickyRows[i].currentEntry = null;
|
|
2672
|
+
}
|
|
2673
|
+
return;
|
|
2674
|
+
}
|
|
2675
|
+
stickyGroupsEl.style.display = "";
|
|
2676
|
+
// Read the FIRST-VISIBLE entry index (no overscan). axis.start()
|
|
2677
|
+
// includes overscan slots above the viewport, so it would show
|
|
2678
|
+
// ancestors of a not-yet-visible entry -- sticky "active" while
|
|
2679
|
+
// the user is already scrolling through "archived" data. Using
|
|
2680
|
+
// firstIndex keeps sticky in lockstep with what's under the
|
|
2681
|
+
// column header line.
|
|
2682
|
+
const ancestors = groupAncestryAt(axis.firstIndex());
|
|
2683
|
+
// Grow the pool of sticky rows to match the current depth.
|
|
2684
|
+
while (_stickyRows.length < ancestors.length) {
|
|
2685
|
+
const info = _buildStickyRow(_stickyRows.length);
|
|
2686
|
+
stickyGroupsEl.appendChild(info.row);
|
|
2687
|
+
_stickyRows.push(info);
|
|
2688
|
+
}
|
|
2689
|
+
// Populate visible slots + hide the rest.
|
|
2690
|
+
const firstKey = firstVisibleColKey();
|
|
2691
|
+
for (let d = 0; d < _stickyRows.length; d++) {
|
|
2692
|
+
const info = _stickyRows[d];
|
|
2693
|
+
const a = ancestors[d];
|
|
2694
|
+
if (a) {
|
|
2695
|
+
info.currentEntry = a;
|
|
2696
|
+
info.row.style.display = "grid";
|
|
2697
|
+
info.row.setAttribute("data-collapsed", a.isCollapsed ? "true" : "false");
|
|
2698
|
+
for (const [colKey, cellEl] of info.cells) {
|
|
2699
|
+
const col = _mountColumnsByKey.get(colKey);
|
|
2700
|
+
if (!col) continue;
|
|
2701
|
+
let text;
|
|
2702
|
+
if (firstKey === colKey) {
|
|
2703
|
+
const chevron = a.isCollapsed ? CHEVRON_COLLAPSED : CHEVRON_EXPANDED;
|
|
2704
|
+
const label = a.value == null ? "(none)" : String(a.value);
|
|
2705
|
+
text = chevron + " " + label + " (" + a.count + ")";
|
|
2706
|
+
} else {
|
|
2707
|
+
text = _formatAggregate(a, col);
|
|
2708
|
+
}
|
|
2709
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2710
|
+
}
|
|
2711
|
+
} else {
|
|
2712
|
+
info.currentEntry = null;
|
|
2713
|
+
info.row.style.display = "none";
|
|
2714
|
+
}
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
|
|
2718
|
+
// Sticky grand-total footer: AFTER .lt-inner in the viewport's flow, so
|
|
2719
|
+
// its natural flow position is at end-of-scroll -- exactly the trigger
|
|
2720
|
+
// condition for `position: sticky; bottom: 0` to pin it at viewport
|
|
2721
|
+
// bottom. When the user scrolls to the very end and the actual last
|
|
2722
|
+
// entry (grand-total, at index entryCount-1) is drawn by the pool at
|
|
2723
|
+
// the same visual y position, the two overlap seamlessly with matching
|
|
2724
|
+
// content (same _formatAggregate call at both sites).
|
|
2725
|
+
const stickyGrandTotalEl = doc.createElement("div");
|
|
2726
|
+
stickyGrandTotalEl.className = "lt-sticky-grand-total";
|
|
2727
|
+
stickyGrandTotalEl.setAttribute("aria-hidden", "true");
|
|
2728
|
+
stickyGrandTotalEl.style.cssText =
|
|
2729
|
+
"position:sticky;bottom:0;left:0;right:0;height:0;z-index:2;pointer-events:none;";
|
|
2730
|
+
viewport.appendChild(stickyGrandTotalEl);
|
|
2731
|
+
|
|
2732
|
+
const stickyGrandTotalRow = doc.createElement("div");
|
|
2733
|
+
// See `_buildStickyRow` note: no `.lt-row` on sticky rows so pool-slot
|
|
2734
|
+
// counters (querySelectorAll(".lt-row")) aren't inflated.
|
|
2735
|
+
stickyGrandTotalRow.className = "lt-row-grand-total lt-sticky-grand-total-row";
|
|
2736
|
+
// Position ABOVE the (height:0) sticky container: `top: -rowHeight` puts
|
|
2737
|
+
// the row's top edge one rowHeight ABOVE the container, so the row's
|
|
2738
|
+
// bottom edge coincides with the container's top -- which is glued to
|
|
2739
|
+
// viewport bottom by the sticky rule on the container. This is more
|
|
2740
|
+
// robust than `bottom: 0` inside a height:0 containing block, where
|
|
2741
|
+
// some browsers resolve "0 from bottom of a 0-height box" as "at the
|
|
2742
|
+
// container's top" (which puts the row BELOW the viewport).
|
|
2743
|
+
stickyGrandTotalRow.style.cssText =
|
|
2744
|
+
"position:absolute;left:0;right:0;" +
|
|
2745
|
+
"top:-" + rowHeight + "px;" +
|
|
2746
|
+
"height:" + rowHeight + "px;" +
|
|
2747
|
+
"display:grid;grid-template-columns:var(--lt-cols);" +
|
|
2748
|
+
"width:max-content;min-width:100%;" +
|
|
2749
|
+
"pointer-events:auto;";
|
|
2750
|
+
const _stickyGtCells = new Map();
|
|
2751
|
+
for (let c = 0; c < columns.length; c++) {
|
|
2752
|
+
const col = columns[c];
|
|
2753
|
+
const cellEl = doc.createElement("div");
|
|
2754
|
+
cellEl.className = "lt-cell";
|
|
2755
|
+
cellEl.setAttribute("data-key", col.key);
|
|
2756
|
+
scope.effect(() => {
|
|
2757
|
+
const placement = colPlacement().get(col.key);
|
|
2758
|
+
if (placement == null) {
|
|
2759
|
+
cellEl.style.display = "none";
|
|
2760
|
+
} else {
|
|
2761
|
+
cellEl.style.display = "";
|
|
2762
|
+
cellEl.style.gridColumn = placement + " / span 1";
|
|
2763
|
+
}
|
|
2764
|
+
});
|
|
2765
|
+
scope.effect(() => {
|
|
2766
|
+
const pinSide = col.pin();
|
|
2767
|
+
cellEl.setAttribute("data-pin", pinSide);
|
|
2768
|
+
if (pinSide === "left") {
|
|
2769
|
+
cellEl.style.left = (leftOffsets().get(col.key) || 0) + "px";
|
|
2770
|
+
cellEl.style.right = "";
|
|
2771
|
+
} else if (pinSide === "right") {
|
|
2772
|
+
cellEl.style.right = (rightOffsets().get(col.key) || 0) + "px";
|
|
2773
|
+
cellEl.style.left = "";
|
|
2774
|
+
} else {
|
|
2775
|
+
cellEl.style.left = "";
|
|
2776
|
+
cellEl.style.right = "";
|
|
2777
|
+
}
|
|
2778
|
+
});
|
|
2779
|
+
stickyGrandTotalRow.appendChild(cellEl);
|
|
2780
|
+
_stickyGtCells.set(col.key, cellEl);
|
|
2781
|
+
}
|
|
2782
|
+
stickyGrandTotalEl.appendChild(stickyGrandTotalRow);
|
|
2783
|
+
|
|
2784
|
+
scope.effect(() => {
|
|
2785
|
+
const entries = visibleEntries();
|
|
2786
|
+
const last = entries.length > 0 ? entries[entries.length - 1] : null;
|
|
2787
|
+
if (!last || last.type !== "grand-total") {
|
|
2788
|
+
stickyGrandTotalEl.style.display = "none";
|
|
2789
|
+
return;
|
|
2790
|
+
}
|
|
2791
|
+
stickyGrandTotalEl.style.display = "";
|
|
2792
|
+
const firstKey = firstVisibleColKey();
|
|
2793
|
+
for (const [colKey, cellEl] of _stickyGtCells) {
|
|
2794
|
+
const col = _mountColumnsByKey.get(colKey);
|
|
2795
|
+
if (!col) continue;
|
|
2796
|
+
let text;
|
|
2797
|
+
if (firstKey === colKey) {
|
|
2798
|
+
text = "Total (" + last.count + ")";
|
|
2799
|
+
} else {
|
|
2800
|
+
text = _formatAggregate(last, col);
|
|
2801
|
+
}
|
|
2802
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2803
|
+
}
|
|
2804
|
+
});
|
|
2805
|
+
|
|
1308
2806
|
// ----- Delegated pointerdown on root ------------------------------------
|
|
1309
2807
|
// One listener instead of pool-size x columns. We use closest('.lt-cell')
|
|
1310
2808
|
// to find the tapped cell, read its data-key for the column, and find the
|
|
@@ -1321,17 +2819,31 @@ export function mountTable(host, table, options) {
|
|
|
1321
2819
|
const poolIdx = slots.indexOf(rowEl);
|
|
1322
2820
|
if (poolIdx < 0) return;
|
|
1323
2821
|
const slotIdx = untrack(() => axis.start()) + poolIdx;
|
|
1324
|
-
const
|
|
1325
|
-
if (slotIdx < 0 || slotIdx >=
|
|
1326
|
-
const
|
|
2822
|
+
const es = untrack(() => visibleEntries());
|
|
2823
|
+
if (slotIdx < 0 || slotIdx >= es.length) return;
|
|
2824
|
+
const entry = es[slotIdx];
|
|
2825
|
+
if (entry == null) return;
|
|
2826
|
+
|
|
2827
|
+
// Group-header rows are toggles, not selections. Any click on the
|
|
2828
|
+
// header collapses/expands its subtree -- we don't wire this to
|
|
2829
|
+
// just the chevron because a bigger hit target is friendlier on
|
|
2830
|
+
// touch, and there's nothing else meaningful to do with a
|
|
2831
|
+
// header-row click. Selection + focus stay untouched.
|
|
2832
|
+
if (entry.type === "group-header") {
|
|
2833
|
+
toggleGroup(entry.path);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
// Grand-total row is decorative -- ignore clicks entirely so it
|
|
2837
|
+
// doesn't clear the current selection when the user taps it.
|
|
2838
|
+
if (entry.type === "grand-total") return;
|
|
2839
|
+
|
|
2840
|
+
const row = entry.row;
|
|
1327
2841
|
if (row == null) return;
|
|
1328
2842
|
const rowId = getRowId(row);
|
|
1329
2843
|
if (ev.shiftKey) selectRow(rowId, "range");
|
|
1330
2844
|
else if (ev.ctrlKey || ev.metaKey) selectRow(rowId, "toggle");
|
|
1331
2845
|
else selectRow(rowId, "set");
|
|
1332
2846
|
focusedCell.set({ rowId, columnKey: colKey });
|
|
1333
|
-
// No preventDefault -- preserves native text selection on mouse and
|
|
1334
|
-
// scroll initiation on touch.
|
|
1335
2847
|
});
|
|
1336
2848
|
|
|
1337
2849
|
// ----- aria-activedescendant --------------------------------------------
|
|
@@ -1426,6 +2938,22 @@ export function mountTable(host, table, options) {
|
|
|
1426
2938
|
if (ctrl) { table.selectAll(); }
|
|
1427
2939
|
else handled = false;
|
|
1428
2940
|
break;
|
|
2941
|
+
// M2: F2 and Enter (no modifiers) on the focused cell start
|
|
2942
|
+
// editing if the column is editable. Matches the spreadsheet
|
|
2943
|
+
// convention -- F2 universally, Enter as a convenience.
|
|
2944
|
+
case "F2":
|
|
2945
|
+
case "Enter": {
|
|
2946
|
+
if (ev.shiftKey || ctrl) { handled = false; break; }
|
|
2947
|
+
const f = focusedCell.peek();
|
|
2948
|
+
if (!f) { handled = false; break; }
|
|
2949
|
+
const col = columns.find((c) => c.key === f.columnKey);
|
|
2950
|
+
if (col && col.editable) {
|
|
2951
|
+
startEdit(f.rowId, f.columnKey);
|
|
2952
|
+
} else {
|
|
2953
|
+
handled = false;
|
|
2954
|
+
}
|
|
2955
|
+
break;
|
|
2956
|
+
}
|
|
1429
2957
|
default:
|
|
1430
2958
|
handled = false;
|
|
1431
2959
|
}
|