@zakkster/lite-table 1.1.0 → 1.3.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 +151 -0
- package/README.md +223 -18
- package/Table.d.ts +178 -0
- package/Table.js +1136 -148
- package/llms.txt +212 -4
- package/package.json +5 -3
package/Table.js
CHANGED
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
60
|
import {
|
|
61
|
-
signal, computed, effect, untrack,
|
|
61
|
+
signal, computed, effect, untrack, batch,
|
|
62
62
|
dispose as disposeNode
|
|
63
63
|
} from "@zakkster/lite-signal";
|
|
64
64
|
import { virtualAxis } from "@zakkster/lite-virtual";
|
|
@@ -213,6 +213,16 @@ function createColumnState(def, scope, defaults) {
|
|
|
213
213
|
// Default: case-insensitive substring match on the stringified value.
|
|
214
214
|
filter: typeof def.filter === "function" ? def.filter : null,
|
|
215
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,
|
|
216
226
|
minWidth,
|
|
217
227
|
maxWidth,
|
|
218
228
|
width,
|
|
@@ -505,44 +515,9 @@ export function createTable(config) {
|
|
|
505
515
|
// stability across the multi-key chain regardless of engine.
|
|
506
516
|
let _sortIdxBuf = null;
|
|
507
517
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
if (!chain.length) return src;
|
|
512
|
-
const n = src.length;
|
|
513
|
-
|
|
514
|
-
if (!_sortIdxBuf || _sortIdxBuf.length < n) {
|
|
515
|
-
// Grow factor of 2 to amortize reallocation.
|
|
516
|
-
const cap = Math.max(n, _sortIdxBuf ? _sortIdxBuf.length * 2 : 1024);
|
|
517
|
-
_sortIdxBuf = new Uint32Array(cap);
|
|
518
|
-
}
|
|
519
|
-
const view = _sortIdxBuf.subarray(0, n);
|
|
520
|
-
for (let i = 0; i < n; i++) view[i] = i;
|
|
521
|
-
|
|
522
|
-
view.sort((iA, iB) => {
|
|
523
|
-
const rowA = src[iA], rowB = src[iB];
|
|
524
|
-
for (let k = 0; k < chain.length; k++) {
|
|
525
|
-
const entry = chain[k];
|
|
526
|
-
const col = columnsByKey.get(entry.key);
|
|
527
|
-
if (!col) continue;
|
|
528
|
-
const av = readCell(col, rowA);
|
|
529
|
-
const bv = readCell(col, rowB);
|
|
530
|
-
const c = col.compare(av, bv);
|
|
531
|
-
if (c !== 0) return entry.dir === "desc" ? -c : c;
|
|
532
|
-
}
|
|
533
|
-
return iA - iB;
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
// Single output allocation: an array of N row references (not row copies).
|
|
537
|
-
// Returning a fresh array each time preserves Object.is inequality so
|
|
538
|
-
// downstream computeds re-evaluate. Reusing this array across calls
|
|
539
|
-
// would silently break consumers that hold the prior reference.
|
|
540
|
-
const out = new Array(n);
|
|
541
|
-
for (let i = 0; i < n; i++) out[i] = src[view[i]];
|
|
542
|
-
return out;
|
|
543
|
-
});
|
|
544
|
-
|
|
545
|
-
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`.)
|
|
546
521
|
|
|
547
522
|
function toggleSort(key, opts) {
|
|
548
523
|
const col = columnsByKey.get(key);
|
|
@@ -617,6 +592,464 @@ export function createTable(config) {
|
|
|
617
592
|
}
|
|
618
593
|
function clearSort() { sortChain.set([]); }
|
|
619
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
|
+
|
|
620
1053
|
// --- Selection ---
|
|
621
1054
|
// The selection state is a PREDICATE, not a list of IDs. Two modes:
|
|
622
1055
|
//
|
|
@@ -1191,6 +1624,165 @@ export function createTable(config) {
|
|
|
1191
1624
|
return indent > 0 ? JSON.stringify(out, null, indent) : JSON.stringify(out);
|
|
1192
1625
|
}
|
|
1193
1626
|
|
|
1627
|
+
// =========================================================================
|
|
1628
|
+
// --- View state (persistence seam, v1.3.0) -------------------------------
|
|
1629
|
+
// =========================================================================
|
|
1630
|
+
//
|
|
1631
|
+
// getViewState() snapshots the LAYOUT + QUERY (sort, per-column layout,
|
|
1632
|
+
// order, filters, grouping) to plain JSON-safe data -- no live signal
|
|
1633
|
+
// refs, no Map/Set instances. setViewState() restores it with REPLACE
|
|
1634
|
+
// semantics, atomically inside batch(), fail-closed on garbage. This is
|
|
1635
|
+
// the seam lite-headless createSavedViews (G-03) consumes; the named-view
|
|
1636
|
+
// MANAGER lives there, not here. Cold user-gesture path, not a hot path.
|
|
1637
|
+
|
|
1638
|
+
// Reconcile a saved column order against the CURRENT column set: keep saved
|
|
1639
|
+
// keys still present (in saved order), append current keys absent from the
|
|
1640
|
+
// saved order, drop saved keys no longer present. Always returns a
|
|
1641
|
+
// permutation of the current columns, so setColumnOrder's non-permutation
|
|
1642
|
+
// guard never trips on a stale-but-valid order.
|
|
1643
|
+
function _reconcileOrder(savedOrder) {
|
|
1644
|
+
const result = [];
|
|
1645
|
+
const used = new Set();
|
|
1646
|
+
if (Array.isArray(savedOrder)) {
|
|
1647
|
+
for (let i = 0; i < savedOrder.length; i++) {
|
|
1648
|
+
const k = savedOrder[i];
|
|
1649
|
+
if (columnsByKey.has(k) && !used.has(k)) {
|
|
1650
|
+
result.push(k);
|
|
1651
|
+
used.add(k);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
const cur = columnOrder();
|
|
1656
|
+
for (let i = 0; i < cur.length; i++) {
|
|
1657
|
+
const k = cur[i];
|
|
1658
|
+
if (!used.has(k)) {
|
|
1659
|
+
result.push(k);
|
|
1660
|
+
used.add(k);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return result;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
function getViewState() {
|
|
1667
|
+
// Full per-column layout for EVERY current column -- restore is
|
|
1668
|
+
// default-independent (never a delta against unknown defaults).
|
|
1669
|
+
const cols = {};
|
|
1670
|
+
for (let i = 0; i < columns.length; i++) {
|
|
1671
|
+
const c = columns[i];
|
|
1672
|
+
cols[c.key] = {
|
|
1673
|
+
width: c.width(),
|
|
1674
|
+
hidden: c.hidden(),
|
|
1675
|
+
pin: c.pin(),
|
|
1676
|
+
flex: c.flex()
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
// Map -> object; skip empty/whitespace queries (they carry no state).
|
|
1680
|
+
const filters = {};
|
|
1681
|
+
for (const [key, q] of columnFilters()) {
|
|
1682
|
+
if (typeof q === "string" && q.trim() !== "") filters[key] = q;
|
|
1683
|
+
}
|
|
1684
|
+
// Copy each sort entry -- no shared refs to the live chain.
|
|
1685
|
+
const sort = [];
|
|
1686
|
+
const chain = sortChain();
|
|
1687
|
+
for (let i = 0; i < chain.length; i++) {
|
|
1688
|
+
sort.push({ key: chain[i].key, dir: chain[i].dir });
|
|
1689
|
+
}
|
|
1690
|
+
// Set -> array of pathStr.
|
|
1691
|
+
const collapsed = [];
|
|
1692
|
+
for (const p of collapsedGroups()) collapsed.push(p);
|
|
1693
|
+
return {
|
|
1694
|
+
v: 1,
|
|
1695
|
+
sort,
|
|
1696
|
+
columnOrder: columnOrder().slice(),
|
|
1697
|
+
columns: cols,
|
|
1698
|
+
filters,
|
|
1699
|
+
groupBy: groupBy().slice(),
|
|
1700
|
+
collapsedGroups: collapsed
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
function setViewState(view, opts) {
|
|
1705
|
+
// Whole-view fail-closed BEFORE any mutation: garbage must never brick
|
|
1706
|
+
// the table, and a future major version must not partially apply.
|
|
1707
|
+
// null is not zero.
|
|
1708
|
+
if (view === null || typeof view !== "object" || Array.isArray(view)) {
|
|
1709
|
+
throw new TypeError("setViewState: view must be a plain object");
|
|
1710
|
+
}
|
|
1711
|
+
if (view.v !== 1) {
|
|
1712
|
+
throw new TypeError(
|
|
1713
|
+
"setViewState: unsupported view version " + String(view.v) +
|
|
1714
|
+
" (expected 1)"
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
// `opts` is accepted and reserved for future use (e.g. a merge mode);
|
|
1718
|
+
// ignored in v1. REPLACE semantics only.
|
|
1719
|
+
void opts;
|
|
1720
|
+
// Atomic: one batch so downstream computeds (visibleRows,
|
|
1721
|
+
// visibleColumns, colTemplate, offsets) recompute ONCE, not per field.
|
|
1722
|
+
batch(() => {
|
|
1723
|
+
// 1. Column order -- reconcile first so the guard never trips.
|
|
1724
|
+
setColumnOrder(_reconcileOrder(view.columnOrder));
|
|
1725
|
+
// 2. Per-column layout via the four public setters. Skip malformed
|
|
1726
|
+
// entries; columns absent from view.columns keep current values.
|
|
1727
|
+
const cols = view.columns;
|
|
1728
|
+
if (cols !== null && typeof cols === "object" && !Array.isArray(cols)) {
|
|
1729
|
+
const keys = Object.keys(cols);
|
|
1730
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1731
|
+
const key = keys[i];
|
|
1732
|
+
if (!columnsByKey.has(key)) continue;
|
|
1733
|
+
const c = cols[key];
|
|
1734
|
+
if (c === null || typeof c !== "object") continue;
|
|
1735
|
+
if (typeof c.width === "number" && Number.isFinite(c.width)) {
|
|
1736
|
+
setColumnWidth(key, c.width);
|
|
1737
|
+
}
|
|
1738
|
+
if (typeof c.hidden === "boolean") setColumnHidden(key, c.hidden);
|
|
1739
|
+
if (c.pin === "left" || c.pin === "right" || c.pin === "none") {
|
|
1740
|
+
setColumnPin(key, c.pin);
|
|
1741
|
+
}
|
|
1742
|
+
if (typeof c.flex === "number" && Number.isFinite(c.flex)) {
|
|
1743
|
+
setColumnFlex(key, c.flex);
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
// 3. Filters (REPLACE) -- clear, then set each named entry.
|
|
1748
|
+
clearColumnFilters();
|
|
1749
|
+
const filters = view.filters;
|
|
1750
|
+
if (filters !== null && typeof filters === "object" && !Array.isArray(filters)) {
|
|
1751
|
+
const keys = Object.keys(filters);
|
|
1752
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1753
|
+
const key = keys[i];
|
|
1754
|
+
const q = filters[key];
|
|
1755
|
+
if (typeof q === "string") setColumnFilter(key, q);
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
// 4. Sort (REPLACE) -- clear, then add each valid entry.
|
|
1759
|
+
clearSort();
|
|
1760
|
+
const sort = view.sort;
|
|
1761
|
+
if (Array.isArray(sort)) {
|
|
1762
|
+
for (let i = 0; i < sort.length; i++) {
|
|
1763
|
+
const e = sort[i];
|
|
1764
|
+
if (e === null || typeof e !== "object") continue;
|
|
1765
|
+
if (e.dir !== "asc" && e.dir !== "desc") continue;
|
|
1766
|
+
addSort(e.key, e.dir);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
// 5. Grouping (REPLACE) -- _normalizeGroupBy drops unknown keys.
|
|
1770
|
+
setGroupBy(view.groupBy);
|
|
1771
|
+
// 6. Collapsed groups (REPLACE) -- expand all, then collapse each
|
|
1772
|
+
// saved path. Saved pathStr strings split back to path arrays;
|
|
1773
|
+
// collapseGroup re-joins them to the identical pathStr.
|
|
1774
|
+
expandAllGroups();
|
|
1775
|
+
const collapsed = view.collapsedGroups;
|
|
1776
|
+
if (Array.isArray(collapsed)) {
|
|
1777
|
+
for (let i = 0; i < collapsed.length; i++) {
|
|
1778
|
+
const p = collapsed[i];
|
|
1779
|
+
if (typeof p === "string") collapseGroup(p.split(GROUP_PATH_SEP));
|
|
1780
|
+
else if (Array.isArray(p)) collapseGroup(p);
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
});
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1194
1786
|
return {
|
|
1195
1787
|
// Static
|
|
1196
1788
|
columns,
|
|
@@ -1205,6 +1797,13 @@ export function createTable(config) {
|
|
|
1205
1797
|
visibleRows,
|
|
1206
1798
|
rowCount,
|
|
1207
1799
|
|
|
1800
|
+
// Reactive: grouping + aggregation (M3)
|
|
1801
|
+
groupBy,
|
|
1802
|
+
collapsedGroups,
|
|
1803
|
+
groupedRows,
|
|
1804
|
+
visibleEntries,
|
|
1805
|
+
entryCount,
|
|
1806
|
+
|
|
1208
1807
|
// Reactive: columns
|
|
1209
1808
|
columnOrder,
|
|
1210
1809
|
visibleColumns,
|
|
@@ -1241,6 +1840,11 @@ export function createTable(config) {
|
|
|
1241
1840
|
// Methods: filters
|
|
1242
1841
|
setColumnFilter, clearColumnFilters,
|
|
1243
1842
|
|
|
1843
|
+
// Methods: grouping (M3)
|
|
1844
|
+
setGroupBy, toggleGroup, expandGroup, collapseGroup,
|
|
1845
|
+
expandAllGroups, collapseAllGroups, isGroupCollapsed,
|
|
1846
|
+
groupAncestryAt,
|
|
1847
|
+
|
|
1244
1848
|
// Methods: editing
|
|
1245
1849
|
startEdit, commitEdit, cancelEdit, isEditing,
|
|
1246
1850
|
|
|
@@ -1250,6 +1854,9 @@ export function createTable(config) {
|
|
|
1250
1854
|
// Methods: focus
|
|
1251
1855
|
moveFocus,
|
|
1252
1856
|
|
|
1857
|
+
// Methods: view state (persistence seam)
|
|
1858
|
+
getViewState, setViewState,
|
|
1859
|
+
|
|
1253
1860
|
// Lifecycle
|
|
1254
1861
|
dispose,
|
|
1255
1862
|
_scope: scope
|
|
@@ -1374,7 +1981,63 @@ const DEFAULT_STYLES =
|
|
|
1374
1981
|
".lt-filter-input{width:100%;padding:3px 6px;font:inherit;font-size:12px;" +
|
|
1375
1982
|
"background:#fff;color:#0f172a;border:1px solid #cbd5e1;border-radius:3px;" +
|
|
1376
1983
|
"outline:none;box-sizing:border-box}" +
|
|
1377
|
-
".lt-filter-input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #dbeafe}"
|
|
1984
|
+
".lt-filter-input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #dbeafe}" +
|
|
1985
|
+
|
|
1986
|
+
// M3: group-header row -- solid background (never striped), bolder
|
|
1987
|
+
// typography, per-depth indent on the first-visible cell via padding.
|
|
1988
|
+
// The chevron + label are already textual so no icon font required.
|
|
1989
|
+
".lt-row-group-header{background:#eff6ff;font-weight:600;" +
|
|
1990
|
+
"border-bottom:1px solid #bfdbfe;cursor:pointer;" +
|
|
1991
|
+
// touch-action:none avoids the browser claiming pointermove for
|
|
1992
|
+
// native scroll before our toggle handler fires on tap.
|
|
1993
|
+
"touch-action:manipulation;user-select:none}" +
|
|
1994
|
+
".lt-row-group-header:hover{background:#dbeafe}" +
|
|
1995
|
+
".lt-row-group-header .lt-cell{background:inherit;font-weight:inherit;" +
|
|
1996
|
+
"color:#1e3a8a}" +
|
|
1997
|
+
// First cell (chevron + label + count) overflows visibly rather than
|
|
1998
|
+
// getting ellipsized -- the label is the group's identity and the user
|
|
1999
|
+
// needs to read it, even when the first column is narrow (id, checkbox).
|
|
2000
|
+
// Non-first cells keep their normal clipping.
|
|
2001
|
+
".lt-row-group-header .lt-cell:first-child{overflow:visible;" +
|
|
2002
|
+
"white-space:nowrap;text-overflow:clip;z-index:1;position:relative}" +
|
|
2003
|
+
// Indent the first cell per depth. CSS attribute selectors keep the
|
|
2004
|
+
// effect purely in CSS -- no per-slot inline style writes.
|
|
2005
|
+
".lt-row-group-header[data-depth=\"0\"] .lt-cell:first-child{padding-left:12px}" +
|
|
2006
|
+
".lt-row-group-header[data-depth=\"1\"] .lt-cell:first-child{padding-left:28px}" +
|
|
2007
|
+
".lt-row-group-header[data-depth=\"2\"] .lt-cell:first-child{padding-left:44px}" +
|
|
2008
|
+
".lt-row-group-header[data-depth=\"3\"] .lt-cell:first-child{padding-left:60px}" +
|
|
2009
|
+
".lt-row-group-header[data-depth=\"4\"] .lt-cell:first-child{padding-left:76px}" +
|
|
2010
|
+
// Collapsed state: dim the row slightly to hint the group is folded.
|
|
2011
|
+
".lt-row-group-header[data-collapsed=\"true\"]{opacity:0.85}" +
|
|
2012
|
+
// Aggregate cells (non-first) get a slightly muted color so the
|
|
2013
|
+
// group's key value (in first cell) reads as the identity.
|
|
2014
|
+
".lt-row-group-header .lt-cell:not(:first-child){color:#3730a3;" +
|
|
2015
|
+
"text-align:right;font-variant-numeric:tabular-nums}" +
|
|
2016
|
+
|
|
2017
|
+
// M3: grand-total row -- pinned appearance (thick top border, sturdy
|
|
2018
|
+
// typography). Sits at the tail of visibleEntries when enabled.
|
|
2019
|
+
".lt-row-grand-total{background:#f0f9ff;font-weight:700;" +
|
|
2020
|
+
"border-top:2px solid #7dd3fc;border-bottom:1px solid #7dd3fc;" +
|
|
2021
|
+
"color:#0c4a6e}" +
|
|
2022
|
+
".lt-row-grand-total .lt-cell{background:inherit;font-weight:inherit;" +
|
|
2023
|
+
"color:inherit}" +
|
|
2024
|
+
// First cell (the "Total (N)" label) overflows visibly rather than
|
|
2025
|
+
// getting ellipsized when the first column is narrow. Same rationale
|
|
2026
|
+
// as `.lt-row-group-header .lt-cell:first-child`.
|
|
2027
|
+
".lt-row-grand-total .lt-cell:first-child{overflow:visible;" +
|
|
2028
|
+
"white-space:nowrap;text-overflow:clip;z-index:1;position:relative}" +
|
|
2029
|
+
".lt-row-grand-total .lt-cell:not(:first-child){text-align:right;" +
|
|
2030
|
+
"font-variant-numeric:tabular-nums}" +
|
|
2031
|
+
|
|
2032
|
+
// M3: sticky overlays -- containers are zero-height so they don't
|
|
2033
|
+
// reserve scroll space. Their absolute-positioned rows sit on top of
|
|
2034
|
+
// the pool via z-index. Subtle bottom-shadow on sticky group headers
|
|
2035
|
+
// and top-shadow on the sticky footer help them float visually above
|
|
2036
|
+
// the data underneath.
|
|
2037
|
+
".lt-sticky-groups{}" +
|
|
2038
|
+
".lt-sticky-group{box-shadow:0 1px 2px rgba(15,23,42,0.08)}" +
|
|
2039
|
+
".lt-sticky-grand-total{}" +
|
|
2040
|
+
".lt-sticky-grand-total-row{box-shadow:0 -1px 2px rgba(15,23,42,0.08)}";
|
|
1378
2041
|
|
|
1379
2042
|
let _stylesInjected = new WeakSet();
|
|
1380
2043
|
function injectStyles(doc) {
|
|
@@ -1455,7 +2118,10 @@ export function mountTable(host, table, options) {
|
|
|
1455
2118
|
setColumnWidth, moveColumn,
|
|
1456
2119
|
// M2 surface
|
|
1457
2120
|
columnFilters, setColumnFilter,
|
|
1458
|
-
editingCell, editingDraft, startEdit, commitEdit, cancelEdit
|
|
2121
|
+
editingCell, editingDraft, startEdit, commitEdit, cancelEdit,
|
|
2122
|
+
// M3 surface
|
|
2123
|
+
visibleEntries, entryCount, groupBy, collapsedGroups, groupedRows,
|
|
2124
|
+
toggleGroup, groupAncestryAt
|
|
1459
2125
|
} = table;
|
|
1460
2126
|
|
|
1461
2127
|
const { scope, dispose: disposeScope } = createScope();
|
|
@@ -1707,7 +2373,11 @@ export function mountTable(host, table, options) {
|
|
|
1707
2373
|
overscan
|
|
1708
2374
|
});
|
|
1709
2375
|
|
|
1710
|
-
|
|
2376
|
+
// The virtual axis reserves scroll height for EVERY visible entry --
|
|
2377
|
+
// data rows AND group-header + grand-total rows all take rowHeight.
|
|
2378
|
+
// rowCount (data-only) is exposed on the core for consumer stats but
|
|
2379
|
+
// never drives the axis, or headers would overlap the last data row.
|
|
2380
|
+
scope.effect(() => { axis.setCount(entryCount()); });
|
|
1711
2381
|
scope.effect(() => { inner.style.height = axis.totalSize() + "px"; });
|
|
1712
2382
|
|
|
1713
2383
|
// ----- Slot pool --------------------------------------------------------
|
|
@@ -1717,6 +2387,37 @@ export function mountTable(host, table, options) {
|
|
|
1717
2387
|
|
|
1718
2388
|
const slots = [];
|
|
1719
2389
|
|
|
2390
|
+
// Reactive: which column is currently first-in-display-order. Group
|
|
2391
|
+
// headers put their chevron + label + count in this column's cell;
|
|
2392
|
+
// the aggregate values go into the rest. Recomputes on show/hide/
|
|
2393
|
+
// reorder -- so a hidden first column bumps the label into the next.
|
|
2394
|
+
const firstVisibleColKey = scope.computed(() => {
|
|
2395
|
+
const cols = visibleColumns();
|
|
2396
|
+
return cols.length > 0 ? cols[0].key : null;
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
// Chevron glyphs -- ASCII-adjacent Unicode that renders reliably
|
|
2400
|
+
// across system fonts without a webfont dependency.
|
|
2401
|
+
const CHEVRON_EXPANDED = "\u25BC"; // ▼
|
|
2402
|
+
const CHEVRON_COLLAPSED = "\u25B6"; // ▶
|
|
2403
|
+
|
|
2404
|
+
// Format an aggregate value for display. Uses the column's
|
|
2405
|
+
// `aggregateFormat` if provided, otherwise falls back to String().
|
|
2406
|
+
// Null aggregates render as empty (they mean "no values to aggregate").
|
|
2407
|
+
function _formatAggregate(entry, col) {
|
|
2408
|
+
if (!entry.aggregates) return "";
|
|
2409
|
+
const v = entry.aggregates.get(col.key);
|
|
2410
|
+
if (v == null) return "";
|
|
2411
|
+
if (col.aggregateFormat) {
|
|
2412
|
+
try { return col.aggregateFormat(v, col, entry.count); }
|
|
2413
|
+
catch (err) {
|
|
2414
|
+
try { console.error("lite-table: aggregateFormat threw:", err); } catch (_) {}
|
|
2415
|
+
return String(v);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
return String(v);
|
|
2419
|
+
}
|
|
2420
|
+
|
|
1720
2421
|
function buildSlot(poolIdx) {
|
|
1721
2422
|
const rowEl = doc.createElement("div");
|
|
1722
2423
|
rowEl.className = "lt-row";
|
|
@@ -1724,16 +2425,29 @@ export function mountTable(host, table, options) {
|
|
|
1724
2425
|
|
|
1725
2426
|
const slotIndex = scope.computed(() => axis.start() + poolIdx);
|
|
1726
2427
|
|
|
2428
|
+
// The one source of truth for what this pool slot renders. Reads
|
|
2429
|
+
// visibleEntries() so it dispatches on entry.type (data /
|
|
2430
|
+
// group-header / grand-total). All row-level and cell-level
|
|
2431
|
+
// effects below read slotEntry rather than visibleRows/visibleEntries
|
|
2432
|
+
// directly -- keeps every effect down to a single-signal read.
|
|
2433
|
+
const slotEntry = scope.computed(() => {
|
|
2434
|
+
const es = visibleEntries();
|
|
2435
|
+
const i = slotIndex();
|
|
2436
|
+
if (i < 0 || i >= es.length) return null;
|
|
2437
|
+
return es[i];
|
|
2438
|
+
});
|
|
2439
|
+
|
|
1727
2440
|
// Position (translateY) -- single transform write per boundary cross.
|
|
1728
2441
|
scope.effect(() => {
|
|
1729
2442
|
const i = slotIndex();
|
|
1730
2443
|
rowEl.style.transform = "translateY(" + (i * rowHeight) + "px)";
|
|
1731
2444
|
});
|
|
1732
2445
|
|
|
1733
|
-
// Visibility & aria-rowindex
|
|
2446
|
+
// Visibility & aria-rowindex driven by entryCount so out-of-bounds
|
|
2447
|
+
// slots hide immediately (e.g. after collapsing a big group).
|
|
1734
2448
|
scope.effect(() => {
|
|
1735
2449
|
const i = slotIndex();
|
|
1736
|
-
const n =
|
|
2450
|
+
const n = entryCount();
|
|
1737
2451
|
if (i < 0 || i >= n) {
|
|
1738
2452
|
rowEl.style.display = "none";
|
|
1739
2453
|
rowEl.removeAttribute("aria-rowindex");
|
|
@@ -1743,28 +2457,58 @@ export function mountTable(host, table, options) {
|
|
|
1743
2457
|
}
|
|
1744
2458
|
});
|
|
1745
2459
|
|
|
1746
|
-
//
|
|
2460
|
+
// Row-type discriminator: group-header + grand-total get their own
|
|
2461
|
+
// classes and data-attributes. Data rows keep the base .lt-row plus
|
|
2462
|
+
// alt striping. `data-depth` on headers lets the stylesheet indent
|
|
2463
|
+
// per depth without JS style writes.
|
|
1747
2464
|
scope.effect(() => {
|
|
2465
|
+
const entry = slotEntry();
|
|
2466
|
+
rowEl.classList.remove("lt-row-group-header", "lt-row-grand-total");
|
|
2467
|
+
if (entry === null) {
|
|
2468
|
+
rowEl.removeAttribute("data-depth");
|
|
2469
|
+
rowEl.removeAttribute("data-collapsed");
|
|
2470
|
+
return;
|
|
2471
|
+
}
|
|
2472
|
+
if (entry.type === "group-header") {
|
|
2473
|
+
rowEl.classList.add("lt-row-group-header");
|
|
2474
|
+
rowEl.setAttribute("data-depth", String(entry.depth));
|
|
2475
|
+
rowEl.setAttribute("data-collapsed", entry.isCollapsed ? "true" : "false");
|
|
2476
|
+
} else if (entry.type === "grand-total") {
|
|
2477
|
+
rowEl.classList.add("lt-row-grand-total");
|
|
2478
|
+
rowEl.removeAttribute("data-depth");
|
|
2479
|
+
rowEl.removeAttribute("data-collapsed");
|
|
2480
|
+
} else {
|
|
2481
|
+
rowEl.removeAttribute("data-depth");
|
|
2482
|
+
rowEl.removeAttribute("data-collapsed");
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
// Alt striping -- data rows only, tied to slotIndex parity of the
|
|
2487
|
+
// data row's ORDINAL POSITION would be ideal, but computing that
|
|
2488
|
+
// requires another walk. Using slotIndex parity (entry position)
|
|
2489
|
+
// gives visually consistent striping across data rows even when
|
|
2490
|
+
// interrupted by group headers -- it just resets at each header.
|
|
2491
|
+
scope.effect(() => {
|
|
2492
|
+
const entry = slotEntry();
|
|
1748
2493
|
const i = slotIndex();
|
|
1749
|
-
|
|
2494
|
+
const isDataAlt = entry !== null && entry.type === "data" && (i & 1);
|
|
2495
|
+
if (isDataAlt) rowEl.classList.add("lt-row-alt");
|
|
1750
2496
|
else rowEl.classList.remove("lt-row-alt");
|
|
1751
2497
|
});
|
|
1752
2498
|
|
|
1753
|
-
// Selection highlight
|
|
1754
|
-
//
|
|
2499
|
+
// Selection highlight -- data rows only. Group headers and grand
|
|
2500
|
+
// total never appear "selected"; clicking them toggles the group
|
|
2501
|
+
// or does nothing rather than adding to selection.
|
|
1755
2502
|
scope.onCleanup(bindClass(rowEl, "is-selected", () => {
|
|
1756
|
-
const
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
return isSelected(getRowId(rs[i]));
|
|
2503
|
+
const entry = slotEntry();
|
|
2504
|
+
if (entry === null || entry.type !== "data") return false;
|
|
2505
|
+
return isSelected(getRowId(entry.row));
|
|
1760
2506
|
}));
|
|
1761
2507
|
|
|
1762
|
-
// aria-selected on the row.
|
|
1763
2508
|
scope.onCleanup(bindAttr(rowEl, "aria-selected", () => {
|
|
1764
|
-
const
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
return isSelected(getRowId(rs[i])) ? "true" : "false";
|
|
2509
|
+
const entry = slotEntry();
|
|
2510
|
+
if (entry === null || entry.type !== "data") return null;
|
|
2511
|
+
return isSelected(getRowId(entry.row)) ? "true" : "false";
|
|
1768
2512
|
}));
|
|
1769
2513
|
|
|
1770
2514
|
// ----- Cells (one per declared column, in DOM config order) ---------
|
|
@@ -1774,8 +2518,6 @@ export function mountTable(host, table, options) {
|
|
|
1774
2518
|
cellEl.className = "lt-cell";
|
|
1775
2519
|
cellEl.setAttribute("role", "gridcell");
|
|
1776
2520
|
cellEl.setAttribute("aria-colindex", String(c + 1));
|
|
1777
|
-
// Static -- never changes for this cell. Read by the delegated
|
|
1778
|
-
// pointerdown handler on root to identify which column was tapped.
|
|
1779
2521
|
cellEl.setAttribute("data-key", col.key);
|
|
1780
2522
|
|
|
1781
2523
|
// Reactive grid placement / hide.
|
|
@@ -1805,100 +2547,103 @@ export function mountTable(host, table, options) {
|
|
|
1805
2547
|
}
|
|
1806
2548
|
});
|
|
1807
2549
|
|
|
1808
|
-
// Reactive text
|
|
1809
|
-
//
|
|
1810
|
-
//
|
|
1811
|
-
// any textContent write would clobber the user's caret.
|
|
2550
|
+
// Reactive text -- dispatches on entry.type. Manual effect (not
|
|
2551
|
+
// bindText) so the editing gate can skip the write when this
|
|
2552
|
+
// cell is the active edit target.
|
|
1812
2553
|
scope.effect(() => {
|
|
1813
|
-
const
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
2554
|
+
const entry = slotEntry();
|
|
2555
|
+
if (entry === null) {
|
|
2556
|
+
if (cellEl.textContent !== "") cellEl.textContent = "";
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
if (entry.type === "data") {
|
|
2561
|
+
// Editing gate: suspend text writes while the user is
|
|
2562
|
+
// typing in this cell -- the contenteditable IS the
|
|
2563
|
+
// source of truth until commitEdit runs. The effect
|
|
2564
|
+
// still tracks editingCell so it resumes cleanly.
|
|
2565
|
+
if (col.editable) {
|
|
2566
|
+
const e = editingCell();
|
|
2567
|
+
if (e !== null && e.rowId === getRowId(entry.row) && e.columnKey === col.key) {
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
const v = readCell(col, entry.row);
|
|
2572
|
+
const text = v == null ? "" : String(v);
|
|
2573
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
|
|
2577
|
+
if (entry.type === "group-header") {
|
|
2578
|
+
// First visible column holds the chevron + label.
|
|
2579
|
+
// (Reads firstVisibleColKey reactively -- column
|
|
2580
|
+
// hide/reorder repaints the affected cells.)
|
|
2581
|
+
if (firstVisibleColKey() === col.key) {
|
|
2582
|
+
const chevron = entry.isCollapsed ? CHEVRON_COLLAPSED : CHEVRON_EXPANDED;
|
|
2583
|
+
const label = entry.value == null ? "(none)" : String(entry.value);
|
|
2584
|
+
const text = chevron + " " + label + " (" + entry.count + ")";
|
|
2585
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2586
|
+
} else {
|
|
2587
|
+
const text = _formatAggregate(entry, col);
|
|
2588
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2589
|
+
}
|
|
2590
|
+
return;
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
if (entry.type === "grand-total") {
|
|
2594
|
+
if (firstVisibleColKey() === col.key) {
|
|
2595
|
+
const text = "Total (" + entry.count + ")";
|
|
2596
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2597
|
+
} else {
|
|
2598
|
+
const text = _formatAggregate(entry, col);
|
|
2599
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
1828
2600
|
}
|
|
2601
|
+
return;
|
|
1829
2602
|
}
|
|
1830
|
-
const v = readCell(col, row);
|
|
1831
|
-
const text = v == null ? "" : String(v);
|
|
1832
|
-
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
1833
2603
|
});
|
|
1834
2604
|
|
|
1835
|
-
// Reactive id (the aria-activedescendant target).
|
|
2605
|
+
// Reactive id (the aria-activedescendant target). Only data
|
|
2606
|
+
// cells get an id -- headers / totals have no rowId.
|
|
1836
2607
|
scope.onCleanup(bindAttr(cellEl, "id", () => {
|
|
1837
|
-
const
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
const row = rs[i];
|
|
1841
|
-
if (row == null) return null;
|
|
1842
|
-
return cellId(getRowId(row), col.key);
|
|
2608
|
+
const entry = slotEntry();
|
|
2609
|
+
if (entry === null || entry.type !== "data") return null;
|
|
2610
|
+
return cellId(getRowId(entry.row), col.key);
|
|
1843
2611
|
}));
|
|
1844
2612
|
|
|
1845
|
-
// Focus indicator.
|
|
1846
|
-
//
|
|
1847
|
-
// cell flips one class; only the 1-2 cells that gain/lose focus
|
|
1848
|
-
// produce classList writes.
|
|
2613
|
+
// Focus indicator. Same restriction -- only data cells can be
|
|
2614
|
+
// focused via the keyboard grid.
|
|
1849
2615
|
scope.onCleanup(bindClass(cellEl, "is-focused", () => {
|
|
1850
|
-
const
|
|
1851
|
-
|
|
1852
|
-
if (i < 0 || i >= rs.length) return false;
|
|
1853
|
-
const row = rs[i];
|
|
1854
|
-
if (row == null) return false;
|
|
2616
|
+
const entry = slotEntry();
|
|
2617
|
+
if (entry === null || entry.type !== "data") return false;
|
|
1855
2618
|
const f = focusedCell();
|
|
1856
2619
|
if (!f) return false;
|
|
1857
|
-
return getRowId(row) === f.rowId && col.key === f.columnKey;
|
|
2620
|
+
return getRowId(entry.row) === f.rowId && col.key === f.columnKey;
|
|
1858
2621
|
}));
|
|
1859
2622
|
|
|
1860
|
-
// Editable
|
|
1861
|
-
//
|
|
1862
|
-
// editable cells stay simple text nodes with no event listeners.
|
|
2623
|
+
// Editable machinery is gated on data rows: group-header cells
|
|
2624
|
+
// are visually plain even for editable columns.
|
|
1863
2625
|
if (col.editable) {
|
|
1864
2626
|
cellEl.setAttribute("data-editable", "true");
|
|
1865
2627
|
|
|
1866
2628
|
// Editing state painted as data + class + contenteditable.
|
|
1867
2629
|
scope.effect(() => {
|
|
1868
|
-
const
|
|
1869
|
-
|
|
1870
|
-
if (i < 0 || i >= rs.length) {
|
|
1871
|
-
cellEl.removeAttribute("contenteditable");
|
|
1872
|
-
cellEl.classList.remove("is-editing");
|
|
1873
|
-
return;
|
|
1874
|
-
}
|
|
1875
|
-
const row = rs[i];
|
|
1876
|
-
if (row == null) {
|
|
2630
|
+
const entry = slotEntry();
|
|
2631
|
+
if (entry === null || entry.type !== "data") {
|
|
1877
2632
|
cellEl.removeAttribute("contenteditable");
|
|
1878
2633
|
cellEl.classList.remove("is-editing");
|
|
1879
2634
|
return;
|
|
1880
2635
|
}
|
|
2636
|
+
const row = entry.row;
|
|
1881
2637
|
const e = editingCell();
|
|
1882
2638
|
const editingThis = e !== null && e.rowId === getRowId(row) && e.columnKey === col.key;
|
|
1883
2639
|
if (editingThis) {
|
|
1884
2640
|
if (cellEl.getAttribute("contenteditable") !== "true") {
|
|
1885
2641
|
cellEl.setAttribute("contenteditable", "true");
|
|
1886
|
-
// Seed textContent with the draft so what the user
|
|
1887
|
-
// sees matches what commitEdit will read. Capture
|
|
1888
|
-
// the draft synchronously to avoid re-running this
|
|
1889
|
-
// effect on every keystroke (draft changes don't
|
|
1890
|
-
// need to re-paint anything else).
|
|
1891
2642
|
const seed = editingDraft.peek();
|
|
1892
2643
|
if (cellEl.textContent !== seed) cellEl.textContent = seed;
|
|
1893
|
-
// Defer focus to the next microtask so any
|
|
1894
|
-
// synchronous DOM rearrangement (slot recycle,
|
|
1895
|
-
// resize) doesn't pre-empt the focus call.
|
|
1896
2644
|
queueMicrotask(() => {
|
|
1897
2645
|
if (cellEl.getAttribute("contenteditable") === "true") {
|
|
1898
2646
|
cellEl.focus();
|
|
1899
|
-
// Select all so a tab into the cell starts
|
|
1900
|
-
// with the whole value highlighted -- the
|
|
1901
|
-
// standard spreadsheet idiom.
|
|
1902
2647
|
const sel = doc.getSelection ? doc.getSelection() : null;
|
|
1903
2648
|
if (sel) {
|
|
1904
2649
|
const range = doc.createRange();
|
|
@@ -1918,33 +2663,23 @@ export function mountTable(host, table, options) {
|
|
|
1918
2663
|
}
|
|
1919
2664
|
});
|
|
1920
2665
|
|
|
1921
|
-
// input event: keep the draft in sync with the visible content
|
|
1922
|
-
// so commitEdit (which reads editingDraft) gets the latest.
|
|
1923
2666
|
scope.on(cellEl, "input", () => {
|
|
1924
|
-
// Only act when this cell is the one being edited. Slot
|
|
1925
|
-
// recycling could in theory fire input on a stale cell
|
|
1926
|
-
// (it shouldn't if contenteditable isn't set, but the
|
|
1927
|
-
// guard is cheap).
|
|
1928
2667
|
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
1929
2668
|
editingDraft.set(cellEl.textContent || "");
|
|
1930
2669
|
});
|
|
1931
2670
|
|
|
1932
|
-
// keydown: Enter commits (and we move down), Escape cancels,
|
|
1933
|
-
// Tab commits (and the browser moves focus).
|
|
1934
2671
|
scope.on(cellEl, "keydown", (ev) => {
|
|
1935
2672
|
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
1936
2673
|
if (ev.key === "Escape") {
|
|
1937
2674
|
ev.preventDefault();
|
|
1938
2675
|
ev.stopPropagation();
|
|
1939
2676
|
cancelEdit();
|
|
1940
|
-
// Restore focus to the root so keyboard nav continues.
|
|
1941
2677
|
root.focus();
|
|
1942
2678
|
} else if (ev.key === "Enter" && !ev.shiftKey) {
|
|
1943
2679
|
ev.preventDefault();
|
|
1944
2680
|
ev.stopPropagation();
|
|
1945
2681
|
commitEdit();
|
|
1946
2682
|
root.focus();
|
|
1947
|
-
// Move focus to the row below if there is one.
|
|
1948
2683
|
moveFocus("down");
|
|
1949
2684
|
} else if (ev.key === "Tab") {
|
|
1950
2685
|
ev.preventDefault();
|
|
@@ -1955,28 +2690,19 @@ export function mountTable(host, table, options) {
|
|
|
1955
2690
|
}
|
|
1956
2691
|
});
|
|
1957
2692
|
|
|
1958
|
-
// blur: commit. Use focusout so it fires reliably across all
|
|
1959
|
-
// browsers and bubbles through the cell.
|
|
1960
2693
|
scope.on(cellEl, "blur", () => {
|
|
1961
2694
|
if (cellEl.getAttribute("contenteditable") !== "true") return;
|
|
1962
2695
|
commitEdit();
|
|
1963
2696
|
});
|
|
1964
2697
|
|
|
1965
|
-
// dblclick: start editing this cell.
|
|
1966
2698
|
scope.on(cellEl, "dblclick", (ev) => {
|
|
1967
|
-
const
|
|
1968
|
-
|
|
1969
|
-
if (i < 0 || i >= rs.length) return;
|
|
1970
|
-
const row = rs[i];
|
|
1971
|
-
if (row == null) return;
|
|
2699
|
+
const entry = slotEntry.peek();
|
|
2700
|
+
if (entry === null || entry.type !== "data") return;
|
|
1972
2701
|
ev.preventDefault();
|
|
1973
|
-
startEdit(getRowId(row), col.key);
|
|
2702
|
+
startEdit(getRowId(entry.row), col.key);
|
|
1974
2703
|
});
|
|
1975
2704
|
}
|
|
1976
2705
|
|
|
1977
|
-
// No pointerdown listener here -- the root has one delegated
|
|
1978
|
-
// listener that uses closest('.lt-cell') + data-key + slot index.
|
|
1979
|
-
|
|
1980
2706
|
rowEl.appendChild(cellEl);
|
|
1981
2707
|
}
|
|
1982
2708
|
|
|
@@ -1991,6 +2717,254 @@ export function mountTable(host, table, options) {
|
|
|
1991
2717
|
while (slots.length < want) slots.push(buildSlot(slots.length));
|
|
1992
2718
|
});
|
|
1993
2719
|
|
|
2720
|
+
// ----- Sticky group-header + grand-total overlays -----------------------
|
|
2721
|
+
// Both are `position: sticky` zero-height containers that live as
|
|
2722
|
+
// DIRECT CHILDREN OF `.lt-viewport`:
|
|
2723
|
+
// .lt-sticky-groups -- inserted BEFORE .lt-inner
|
|
2724
|
+
// .lt-sticky-grand-total -- appended AFTER .lt-inner
|
|
2725
|
+
// This matters because `position: sticky` is relative to the element's
|
|
2726
|
+
// natural flow position. Putting them inside .lt-inner (where the pool
|
|
2727
|
+
// slots live absolute) would give both containers a natural position
|
|
2728
|
+
// of 0 -- fine for `top: 32`, WRONG for `bottom: 0` (that only kicks
|
|
2729
|
+
// in when the natural position is BELOW viewport bottom, so a footer
|
|
2730
|
+
// at flow-top just... sits at the top). Placing them around .lt-inner
|
|
2731
|
+
// -- whose height reflects the scrollable content -- gives each the
|
|
2732
|
+
// natural position that matches the sticky edge it's aiming for.
|
|
2733
|
+
//
|
|
2734
|
+
// Design invariants:
|
|
2735
|
+
// - Sticky headers show the ANCESTORS of visibleEntries[axis.start()].
|
|
2736
|
+
// If the top-visible entry is itself a group header, its own row is
|
|
2737
|
+
// drawn by the pool at translateY(start * rowHeight); sticky shows
|
|
2738
|
+
// only strictly-shallower ancestors, which is [] for a depth-0
|
|
2739
|
+
// header. No duplication.
|
|
2740
|
+
// - Sticky grand-total mirrors the last entry's aggregates. When the
|
|
2741
|
+
// inline grand-total row is scrolled into view, the sticky row sits
|
|
2742
|
+
// on top of it -- same content, no visible difference.
|
|
2743
|
+
// - Both are hidden when their prerequisites aren't met (no grouping,
|
|
2744
|
+
// no grand total configured). Neither injects DOM or effects into
|
|
2745
|
+
// the ungrouped fast path beyond the two guarding effects.
|
|
2746
|
+
|
|
2747
|
+
// Local lookup so sticky effects don't have to walk `columns` linearly.
|
|
2748
|
+
const _mountColumnsByKey = new Map(columns.map(c => [c.key, c]));
|
|
2749
|
+
|
|
2750
|
+
// Sticky group-headers: BEFORE .lt-inner in the viewport's flow, sticks
|
|
2751
|
+
// at viewport top:<headerHeight> so it clears the sticky column header
|
|
2752
|
+
// (which itself sits at top:0). We use rowHeight for the header height
|
|
2753
|
+
// since that matches the padding+content of `.lt-header-cell` -- if a
|
|
2754
|
+
// consumer restyles the header taller, they'll want a bigger offset.
|
|
2755
|
+
const stickyGroupsEl = doc.createElement("div");
|
|
2756
|
+
stickyGroupsEl.className = "lt-sticky-groups";
|
|
2757
|
+
stickyGroupsEl.setAttribute("aria-hidden", "true");
|
|
2758
|
+
stickyGroupsEl.style.cssText =
|
|
2759
|
+
"position:sticky;top:" + rowHeight + "px;" +
|
|
2760
|
+
"left:0;right:0;height:0;z-index:2;pointer-events:none;";
|
|
2761
|
+
viewport.insertBefore(stickyGroupsEl, inner);
|
|
2762
|
+
|
|
2763
|
+
const _stickyRows = [];
|
|
2764
|
+
function _buildStickyRow(depth) {
|
|
2765
|
+
const rowEl = doc.createElement("div");
|
|
2766
|
+
// NOTE: no `.lt-row` on sticky rows -- the base class is used by
|
|
2767
|
+
// the 1.1.0 test suite (and by consumers) to count pool slots via
|
|
2768
|
+
// `querySelectorAll(".lt-row")`. Sticky rows carry only their
|
|
2769
|
+
// discriminator classes; the grid layout that `.lt-row` provides
|
|
2770
|
+
// is inlined below (display:grid + grid-template-columns).
|
|
2771
|
+
rowEl.className = "lt-row-group-header lt-sticky-group";
|
|
2772
|
+
rowEl.setAttribute("data-depth", String(depth));
|
|
2773
|
+
rowEl.style.cssText =
|
|
2774
|
+
"position:absolute;left:0;right:0;" +
|
|
2775
|
+
"top:" + (depth * rowHeight) + "px;" +
|
|
2776
|
+
"height:" + rowHeight + "px;" +
|
|
2777
|
+
"display:grid;grid-template-columns:var(--lt-cols);" +
|
|
2778
|
+
"width:max-content;min-width:100%;" +
|
|
2779
|
+
"pointer-events:auto;";
|
|
2780
|
+
const cells = new Map();
|
|
2781
|
+
for (let c = 0; c < columns.length; c++) {
|
|
2782
|
+
const col = columns[c];
|
|
2783
|
+
const cellEl = doc.createElement("div");
|
|
2784
|
+
cellEl.className = "lt-cell";
|
|
2785
|
+
cellEl.setAttribute("data-key", col.key);
|
|
2786
|
+
// Reactive per-cell grid placement + pin, matching pool cells so
|
|
2787
|
+
// sticky rows track column reorder / hide / pin the same way.
|
|
2788
|
+
scope.effect(() => {
|
|
2789
|
+
const placement = colPlacement().get(col.key);
|
|
2790
|
+
if (placement == null) {
|
|
2791
|
+
cellEl.style.display = "none";
|
|
2792
|
+
} else {
|
|
2793
|
+
cellEl.style.display = "";
|
|
2794
|
+
cellEl.style.gridColumn = placement + " / span 1";
|
|
2795
|
+
}
|
|
2796
|
+
});
|
|
2797
|
+
scope.effect(() => {
|
|
2798
|
+
const pinSide = col.pin();
|
|
2799
|
+
cellEl.setAttribute("data-pin", pinSide);
|
|
2800
|
+
if (pinSide === "left") {
|
|
2801
|
+
cellEl.style.left = (leftOffsets().get(col.key) || 0) + "px";
|
|
2802
|
+
cellEl.style.right = "";
|
|
2803
|
+
} else if (pinSide === "right") {
|
|
2804
|
+
cellEl.style.right = (rightOffsets().get(col.key) || 0) + "px";
|
|
2805
|
+
cellEl.style.left = "";
|
|
2806
|
+
} else {
|
|
2807
|
+
cellEl.style.left = "";
|
|
2808
|
+
cellEl.style.right = "";
|
|
2809
|
+
}
|
|
2810
|
+
});
|
|
2811
|
+
rowEl.appendChild(cellEl);
|
|
2812
|
+
cells.set(col.key, cellEl);
|
|
2813
|
+
}
|
|
2814
|
+
// Toggle the group by clicking anywhere on the sticky row. Reads the
|
|
2815
|
+
// closure-captured info.currentEntry so we always toggle the group
|
|
2816
|
+
// the row is currently showing, not the one it was built for.
|
|
2817
|
+
const info = { row: rowEl, cells, currentEntry: null };
|
|
2818
|
+
scope.on(rowEl, "pointerdown", (ev) => {
|
|
2819
|
+
if (!ev.isPrimary || ev.button !== 0) return;
|
|
2820
|
+
if (info.currentEntry) toggleGroup(info.currentEntry.path);
|
|
2821
|
+
});
|
|
2822
|
+
return info;
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// Reactive sync: watch axis.start() + visibleEntries + column changes.
|
|
2826
|
+
// Emits/hides sticky rows to match `groupAncestryAt(axis.start())`.
|
|
2827
|
+
scope.effect(() => {
|
|
2828
|
+
// Ungrouped fast path: hide everything and skip.
|
|
2829
|
+
if (groupBy().length === 0) {
|
|
2830
|
+
stickyGroupsEl.style.display = "none";
|
|
2831
|
+
for (let i = 0; i < _stickyRows.length; i++) {
|
|
2832
|
+
_stickyRows[i].row.style.display = "none";
|
|
2833
|
+
_stickyRows[i].currentEntry = null;
|
|
2834
|
+
}
|
|
2835
|
+
return;
|
|
2836
|
+
}
|
|
2837
|
+
stickyGroupsEl.style.display = "";
|
|
2838
|
+
// Read the FIRST-VISIBLE entry index (no overscan). axis.start()
|
|
2839
|
+
// includes overscan slots above the viewport, so it would show
|
|
2840
|
+
// ancestors of a not-yet-visible entry -- sticky "active" while
|
|
2841
|
+
// the user is already scrolling through "archived" data. Using
|
|
2842
|
+
// firstIndex keeps sticky in lockstep with what's under the
|
|
2843
|
+
// column header line.
|
|
2844
|
+
const ancestors = groupAncestryAt(axis.firstIndex());
|
|
2845
|
+
// Grow the pool of sticky rows to match the current depth.
|
|
2846
|
+
while (_stickyRows.length < ancestors.length) {
|
|
2847
|
+
const info = _buildStickyRow(_stickyRows.length);
|
|
2848
|
+
stickyGroupsEl.appendChild(info.row);
|
|
2849
|
+
_stickyRows.push(info);
|
|
2850
|
+
}
|
|
2851
|
+
// Populate visible slots + hide the rest.
|
|
2852
|
+
const firstKey = firstVisibleColKey();
|
|
2853
|
+
for (let d = 0; d < _stickyRows.length; d++) {
|
|
2854
|
+
const info = _stickyRows[d];
|
|
2855
|
+
const a = ancestors[d];
|
|
2856
|
+
if (a) {
|
|
2857
|
+
info.currentEntry = a;
|
|
2858
|
+
info.row.style.display = "grid";
|
|
2859
|
+
info.row.setAttribute("data-collapsed", a.isCollapsed ? "true" : "false");
|
|
2860
|
+
for (const [colKey, cellEl] of info.cells) {
|
|
2861
|
+
const col = _mountColumnsByKey.get(colKey);
|
|
2862
|
+
if (!col) continue;
|
|
2863
|
+
let text;
|
|
2864
|
+
if (firstKey === colKey) {
|
|
2865
|
+
const chevron = a.isCollapsed ? CHEVRON_COLLAPSED : CHEVRON_EXPANDED;
|
|
2866
|
+
const label = a.value == null ? "(none)" : String(a.value);
|
|
2867
|
+
text = chevron + " " + label + " (" + a.count + ")";
|
|
2868
|
+
} else {
|
|
2869
|
+
text = _formatAggregate(a, col);
|
|
2870
|
+
}
|
|
2871
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2872
|
+
}
|
|
2873
|
+
} else {
|
|
2874
|
+
info.currentEntry = null;
|
|
2875
|
+
info.row.style.display = "none";
|
|
2876
|
+
}
|
|
2877
|
+
}
|
|
2878
|
+
});
|
|
2879
|
+
|
|
2880
|
+
// Sticky grand-total footer: AFTER .lt-inner in the viewport's flow, so
|
|
2881
|
+
// its natural flow position is at end-of-scroll -- exactly the trigger
|
|
2882
|
+
// condition for `position: sticky; bottom: 0` to pin it at viewport
|
|
2883
|
+
// bottom. When the user scrolls to the very end and the actual last
|
|
2884
|
+
// entry (grand-total, at index entryCount-1) is drawn by the pool at
|
|
2885
|
+
// the same visual y position, the two overlap seamlessly with matching
|
|
2886
|
+
// content (same _formatAggregate call at both sites).
|
|
2887
|
+
const stickyGrandTotalEl = doc.createElement("div");
|
|
2888
|
+
stickyGrandTotalEl.className = "lt-sticky-grand-total";
|
|
2889
|
+
stickyGrandTotalEl.setAttribute("aria-hidden", "true");
|
|
2890
|
+
stickyGrandTotalEl.style.cssText =
|
|
2891
|
+
"position:sticky;bottom:0;left:0;right:0;height:0;z-index:2;pointer-events:none;";
|
|
2892
|
+
viewport.appendChild(stickyGrandTotalEl);
|
|
2893
|
+
|
|
2894
|
+
const stickyGrandTotalRow = doc.createElement("div");
|
|
2895
|
+
// See `_buildStickyRow` note: no `.lt-row` on sticky rows so pool-slot
|
|
2896
|
+
// counters (querySelectorAll(".lt-row")) aren't inflated.
|
|
2897
|
+
stickyGrandTotalRow.className = "lt-row-grand-total lt-sticky-grand-total-row";
|
|
2898
|
+
// Position ABOVE the (height:0) sticky container: `top: -rowHeight` puts
|
|
2899
|
+
// the row's top edge one rowHeight ABOVE the container, so the row's
|
|
2900
|
+
// bottom edge coincides with the container's top -- which is glued to
|
|
2901
|
+
// viewport bottom by the sticky rule on the container. This is more
|
|
2902
|
+
// robust than `bottom: 0` inside a height:0 containing block, where
|
|
2903
|
+
// some browsers resolve "0 from bottom of a 0-height box" as "at the
|
|
2904
|
+
// container's top" (which puts the row BELOW the viewport).
|
|
2905
|
+
stickyGrandTotalRow.style.cssText =
|
|
2906
|
+
"position:absolute;left:0;right:0;" +
|
|
2907
|
+
"top:-" + rowHeight + "px;" +
|
|
2908
|
+
"height:" + rowHeight + "px;" +
|
|
2909
|
+
"display:grid;grid-template-columns:var(--lt-cols);" +
|
|
2910
|
+
"width:max-content;min-width:100%;" +
|
|
2911
|
+
"pointer-events:auto;";
|
|
2912
|
+
const _stickyGtCells = new Map();
|
|
2913
|
+
for (let c = 0; c < columns.length; c++) {
|
|
2914
|
+
const col = columns[c];
|
|
2915
|
+
const cellEl = doc.createElement("div");
|
|
2916
|
+
cellEl.className = "lt-cell";
|
|
2917
|
+
cellEl.setAttribute("data-key", col.key);
|
|
2918
|
+
scope.effect(() => {
|
|
2919
|
+
const placement = colPlacement().get(col.key);
|
|
2920
|
+
if (placement == null) {
|
|
2921
|
+
cellEl.style.display = "none";
|
|
2922
|
+
} else {
|
|
2923
|
+
cellEl.style.display = "";
|
|
2924
|
+
cellEl.style.gridColumn = placement + " / span 1";
|
|
2925
|
+
}
|
|
2926
|
+
});
|
|
2927
|
+
scope.effect(() => {
|
|
2928
|
+
const pinSide = col.pin();
|
|
2929
|
+
cellEl.setAttribute("data-pin", pinSide);
|
|
2930
|
+
if (pinSide === "left") {
|
|
2931
|
+
cellEl.style.left = (leftOffsets().get(col.key) || 0) + "px";
|
|
2932
|
+
cellEl.style.right = "";
|
|
2933
|
+
} else if (pinSide === "right") {
|
|
2934
|
+
cellEl.style.right = (rightOffsets().get(col.key) || 0) + "px";
|
|
2935
|
+
cellEl.style.left = "";
|
|
2936
|
+
} else {
|
|
2937
|
+
cellEl.style.left = "";
|
|
2938
|
+
cellEl.style.right = "";
|
|
2939
|
+
}
|
|
2940
|
+
});
|
|
2941
|
+
stickyGrandTotalRow.appendChild(cellEl);
|
|
2942
|
+
_stickyGtCells.set(col.key, cellEl);
|
|
2943
|
+
}
|
|
2944
|
+
stickyGrandTotalEl.appendChild(stickyGrandTotalRow);
|
|
2945
|
+
|
|
2946
|
+
scope.effect(() => {
|
|
2947
|
+
const entries = visibleEntries();
|
|
2948
|
+
const last = entries.length > 0 ? entries[entries.length - 1] : null;
|
|
2949
|
+
if (!last || last.type !== "grand-total") {
|
|
2950
|
+
stickyGrandTotalEl.style.display = "none";
|
|
2951
|
+
return;
|
|
2952
|
+
}
|
|
2953
|
+
stickyGrandTotalEl.style.display = "";
|
|
2954
|
+
const firstKey = firstVisibleColKey();
|
|
2955
|
+
for (const [colKey, cellEl] of _stickyGtCells) {
|
|
2956
|
+
const col = _mountColumnsByKey.get(colKey);
|
|
2957
|
+
if (!col) continue;
|
|
2958
|
+
let text;
|
|
2959
|
+
if (firstKey === colKey) {
|
|
2960
|
+
text = "Total (" + last.count + ")";
|
|
2961
|
+
} else {
|
|
2962
|
+
text = _formatAggregate(last, col);
|
|
2963
|
+
}
|
|
2964
|
+
if (cellEl.textContent !== text) cellEl.textContent = text;
|
|
2965
|
+
}
|
|
2966
|
+
});
|
|
2967
|
+
|
|
1994
2968
|
// ----- Delegated pointerdown on root ------------------------------------
|
|
1995
2969
|
// One listener instead of pool-size x columns. We use closest('.lt-cell')
|
|
1996
2970
|
// to find the tapped cell, read its data-key for the column, and find the
|
|
@@ -2007,17 +2981,31 @@ export function mountTable(host, table, options) {
|
|
|
2007
2981
|
const poolIdx = slots.indexOf(rowEl);
|
|
2008
2982
|
if (poolIdx < 0) return;
|
|
2009
2983
|
const slotIdx = untrack(() => axis.start()) + poolIdx;
|
|
2010
|
-
const
|
|
2011
|
-
if (slotIdx < 0 || slotIdx >=
|
|
2012
|
-
const
|
|
2984
|
+
const es = untrack(() => visibleEntries());
|
|
2985
|
+
if (slotIdx < 0 || slotIdx >= es.length) return;
|
|
2986
|
+
const entry = es[slotIdx];
|
|
2987
|
+
if (entry == null) return;
|
|
2988
|
+
|
|
2989
|
+
// Group-header rows are toggles, not selections. Any click on the
|
|
2990
|
+
// header collapses/expands its subtree -- we don't wire this to
|
|
2991
|
+
// just the chevron because a bigger hit target is friendlier on
|
|
2992
|
+
// touch, and there's nothing else meaningful to do with a
|
|
2993
|
+
// header-row click. Selection + focus stay untouched.
|
|
2994
|
+
if (entry.type === "group-header") {
|
|
2995
|
+
toggleGroup(entry.path);
|
|
2996
|
+
return;
|
|
2997
|
+
}
|
|
2998
|
+
// Grand-total row is decorative -- ignore clicks entirely so it
|
|
2999
|
+
// doesn't clear the current selection when the user taps it.
|
|
3000
|
+
if (entry.type === "grand-total") return;
|
|
3001
|
+
|
|
3002
|
+
const row = entry.row;
|
|
2013
3003
|
if (row == null) return;
|
|
2014
3004
|
const rowId = getRowId(row);
|
|
2015
3005
|
if (ev.shiftKey) selectRow(rowId, "range");
|
|
2016
3006
|
else if (ev.ctrlKey || ev.metaKey) selectRow(rowId, "toggle");
|
|
2017
3007
|
else selectRow(rowId, "set");
|
|
2018
3008
|
focusedCell.set({ rowId, columnKey: colKey });
|
|
2019
|
-
// No preventDefault -- preserves native text selection on mouse and
|
|
2020
|
-
// scroll initiation on touch.
|
|
2021
3009
|
});
|
|
2022
3010
|
|
|
2023
3011
|
// ----- aria-activedescendant --------------------------------------------
|