@zakkster/lite-table 1.1.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/Table.js CHANGED
@@ -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
- const visibleRows = scope.computed(() => {
509
- const src = filteredRows();
510
- const chain = sortChain();
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
  //
@@ -1205,6 +1638,13 @@ export function createTable(config) {
1205
1638
  visibleRows,
1206
1639
  rowCount,
1207
1640
 
1641
+ // Reactive: grouping + aggregation (M3)
1642
+ groupBy,
1643
+ collapsedGroups,
1644
+ groupedRows,
1645
+ visibleEntries,
1646
+ entryCount,
1647
+
1208
1648
  // Reactive: columns
1209
1649
  columnOrder,
1210
1650
  visibleColumns,
@@ -1241,6 +1681,11 @@ export function createTable(config) {
1241
1681
  // Methods: filters
1242
1682
  setColumnFilter, clearColumnFilters,
1243
1683
 
1684
+ // Methods: grouping (M3)
1685
+ setGroupBy, toggleGroup, expandGroup, collapseGroup,
1686
+ expandAllGroups, collapseAllGroups, isGroupCollapsed,
1687
+ groupAncestryAt,
1688
+
1244
1689
  // Methods: editing
1245
1690
  startEdit, commitEdit, cancelEdit, isEditing,
1246
1691
 
@@ -1374,7 +1819,63 @@ const DEFAULT_STYLES =
1374
1819
  ".lt-filter-input{width:100%;padding:3px 6px;font:inherit;font-size:12px;" +
1375
1820
  "background:#fff;color:#0f172a;border:1px solid #cbd5e1;border-radius:3px;" +
1376
1821
  "outline:none;box-sizing:border-box}" +
1377
- ".lt-filter-input:focus{border-color:#3b82f6;box-shadow:0 0 0 2px #dbeafe}";
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)}";
1378
1879
 
1379
1880
  let _stylesInjected = new WeakSet();
1380
1881
  function injectStyles(doc) {
@@ -1455,7 +1956,10 @@ export function mountTable(host, table, options) {
1455
1956
  setColumnWidth, moveColumn,
1456
1957
  // M2 surface
1457
1958
  columnFilters, setColumnFilter,
1458
- editingCell, editingDraft, startEdit, commitEdit, cancelEdit
1959
+ editingCell, editingDraft, startEdit, commitEdit, cancelEdit,
1960
+ // M3 surface
1961
+ visibleEntries, entryCount, groupBy, collapsedGroups, groupedRows,
1962
+ toggleGroup, groupAncestryAt
1459
1963
  } = table;
1460
1964
 
1461
1965
  const { scope, dispose: disposeScope } = createScope();
@@ -1707,7 +2211,11 @@ export function mountTable(host, table, options) {
1707
2211
  overscan
1708
2212
  });
1709
2213
 
1710
- scope.effect(() => { axis.setCount(rowCount()); });
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()); });
1711
2219
  scope.effect(() => { inner.style.height = axis.totalSize() + "px"; });
1712
2220
 
1713
2221
  // ----- Slot pool --------------------------------------------------------
@@ -1717,6 +2225,37 @@ export function mountTable(host, table, options) {
1717
2225
 
1718
2226
  const slots = [];
1719
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
+
1720
2259
  function buildSlot(poolIdx) {
1721
2260
  const rowEl = doc.createElement("div");
1722
2261
  rowEl.className = "lt-row";
@@ -1724,16 +2263,29 @@ export function mountTable(host, table, options) {
1724
2263
 
1725
2264
  const slotIndex = scope.computed(() => axis.start() + poolIdx);
1726
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
+
1727
2278
  // Position (translateY) -- single transform write per boundary cross.
1728
2279
  scope.effect(() => {
1729
2280
  const i = slotIndex();
1730
2281
  rowEl.style.transform = "translateY(" + (i * rowHeight) + "px)";
1731
2282
  });
1732
2283
 
1733
- // 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).
1734
2286
  scope.effect(() => {
1735
2287
  const i = slotIndex();
1736
- const n = rowCount();
2288
+ const n = entryCount();
1737
2289
  if (i < 0 || i >= n) {
1738
2290
  rowEl.style.display = "none";
1739
2291
  rowEl.removeAttribute("aria-rowindex");
@@ -1743,28 +2295,58 @@ export function mountTable(host, table, options) {
1743
2295
  }
1744
2296
  });
1745
2297
 
1746
- // Alt striping.
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.
1747
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.
2329
+ scope.effect(() => {
2330
+ const entry = slotEntry();
1748
2331
  const i = slotIndex();
1749
- if (i & 1) rowEl.classList.add("lt-row-alt");
2332
+ const isDataAlt = entry !== null && entry.type === "data" && (i & 1);
2333
+ if (isDataAlt) rowEl.classList.add("lt-row-alt");
1750
2334
  else rowEl.classList.remove("lt-row-alt");
1751
2335
  });
1752
2336
 
1753
- // Selection highlight: bindClass calls isSelected (the predicate),
1754
- // which transparently handles both whitelist and all-mode selection.
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.
1755
2340
  scope.onCleanup(bindClass(rowEl, "is-selected", () => {
1756
- const i = slotIndex();
1757
- const rs = visibleRows();
1758
- if (i < 0 || i >= rs.length) return false;
1759
- return isSelected(getRowId(rs[i]));
2341
+ const entry = slotEntry();
2342
+ if (entry === null || entry.type !== "data") return false;
2343
+ return isSelected(getRowId(entry.row));
1760
2344
  }));
1761
2345
 
1762
- // aria-selected on the row.
1763
2346
  scope.onCleanup(bindAttr(rowEl, "aria-selected", () => {
1764
- const i = slotIndex();
1765
- const rs = visibleRows();
1766
- if (i < 0 || i >= rs.length) return null;
1767
- 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";
1768
2350
  }));
1769
2351
 
1770
2352
  // ----- Cells (one per declared column, in DOM config order) ---------
@@ -1774,8 +2356,6 @@ export function mountTable(host, table, options) {
1774
2356
  cellEl.className = "lt-cell";
1775
2357
  cellEl.setAttribute("role", "gridcell");
1776
2358
  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
2359
  cellEl.setAttribute("data-key", col.key);
1780
2360
 
1781
2361
  // Reactive grid placement / hide.
@@ -1805,100 +2385,103 @@ export function mountTable(host, table, options) {
1805
2385
  }
1806
2386
  });
1807
2387
 
1808
- // Reactive text. We use a manual effect (not bindText) so we can
1809
- // skip the write when this cell is currently being edited -- the
1810
- // contenteditable cell IS the source of truth while editing, and
1811
- // any textContent write would clobber the user's caret.
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.
1812
2391
  scope.effect(() => {
1813
- const i = slotIndex();
1814
- const rs = visibleRows();
1815
- if (i < 0 || i >= rs.length) { cellEl.textContent = ""; return; }
1816
- const row = rs[i];
1817
- // Editing gate: suspend text writes for the cell currently in
1818
- // edit mode. The effect still tracks `editingCell` so it
1819
- // resumes the moment editing ends. Tracking visibleRows + col
1820
- // here means scrolling-during-edit re-points the slot to a
1821
- // different row; we commit the in-flight edit in that case
1822
- // (see slot-row scroll effect below) so the suspended write
1823
- // resumes with the right row's data.
1824
- if (col.editable) {
1825
- const e = editingCell();
1826
- if (e !== null && row != null && e.rowId === getRowId(row) && e.columnKey === col.key) {
1827
- return;
2392
+ const entry = slotEntry();
2393
+ if (entry === null) {
2394
+ if (cellEl.textContent !== "") cellEl.textContent = "";
2395
+ return;
2396
+ }
2397
+
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;
1828
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;
1829
2440
  }
1830
- const v = readCell(col, row);
1831
- const text = v == null ? "" : String(v);
1832
- if (cellEl.textContent !== text) cellEl.textContent = text;
1833
2441
  });
1834
2442
 
1835
- // Reactive id (the aria-activedescendant target).
2443
+ // Reactive id (the aria-activedescendant target). Only data
2444
+ // cells get an id -- headers / totals have no rowId.
1836
2445
  scope.onCleanup(bindAttr(cellEl, "id", () => {
1837
- const i = slotIndex();
1838
- const rs = visibleRows();
1839
- if (i < 0 || i >= rs.length) return null;
1840
- const row = rs[i];
1841
- if (row == null) return null;
1842
- 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);
1843
2449
  }));
1844
2450
 
1845
- // Focus indicator. Cheaper than rewriting a <style> element's
1846
- // textContent because that invalidates CSSOM globally. Here each
1847
- // cell flips one class; only the 1-2 cells that gain/lose focus
1848
- // produce classList writes.
2451
+ // Focus indicator. Same restriction -- only data cells can be
2452
+ // focused via the keyboard grid.
1849
2453
  scope.onCleanup(bindClass(cellEl, "is-focused", () => {
1850
- const i = slotIndex();
1851
- const rs = visibleRows();
1852
- if (i < 0 || i >= rs.length) return false;
1853
- const row = rs[i];
1854
- if (row == null) return false;
2454
+ const entry = slotEntry();
2455
+ if (entry === null || entry.type !== "data") return false;
1855
2456
  const f = focusedCell();
1856
2457
  if (!f) return false;
1857
- return getRowId(row) === f.rowId && col.key === f.columnKey;
2458
+ return getRowId(entry.row) === f.rowId && col.key === f.columnKey;
1858
2459
  }));
1859
2460
 
1860
- // Editable cells: manage contenteditable + the editing-flash class.
1861
- // We only attach this machinery for columns that opted in -- non-
1862
- // editable cells stay simple text nodes with no event listeners.
2461
+ // Editable machinery is gated on data rows: group-header cells
2462
+ // are visually plain even for editable columns.
1863
2463
  if (col.editable) {
1864
2464
  cellEl.setAttribute("data-editable", "true");
1865
2465
 
1866
2466
  // Editing state painted as data + class + contenteditable.
1867
2467
  scope.effect(() => {
1868
- const i = slotIndex();
1869
- const rs = visibleRows();
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) {
2468
+ const entry = slotEntry();
2469
+ if (entry === null || entry.type !== "data") {
1877
2470
  cellEl.removeAttribute("contenteditable");
1878
2471
  cellEl.classList.remove("is-editing");
1879
2472
  return;
1880
2473
  }
2474
+ const row = entry.row;
1881
2475
  const e = editingCell();
1882
2476
  const editingThis = e !== null && e.rowId === getRowId(row) && e.columnKey === col.key;
1883
2477
  if (editingThis) {
1884
2478
  if (cellEl.getAttribute("contenteditable") !== "true") {
1885
2479
  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
2480
  const seed = editingDraft.peek();
1892
2481
  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
2482
  queueMicrotask(() => {
1897
2483
  if (cellEl.getAttribute("contenteditable") === "true") {
1898
2484
  cellEl.focus();
1899
- // Select all so a tab into the cell starts
1900
- // with the whole value highlighted -- the
1901
- // standard spreadsheet idiom.
1902
2485
  const sel = doc.getSelection ? doc.getSelection() : null;
1903
2486
  if (sel) {
1904
2487
  const range = doc.createRange();
@@ -1918,33 +2501,23 @@ export function mountTable(host, table, options) {
1918
2501
  }
1919
2502
  });
1920
2503
 
1921
- // input event: keep the draft in sync with the visible content
1922
- // so commitEdit (which reads editingDraft) gets the latest.
1923
2504
  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
2505
  if (cellEl.getAttribute("contenteditable") !== "true") return;
1929
2506
  editingDraft.set(cellEl.textContent || "");
1930
2507
  });
1931
2508
 
1932
- // keydown: Enter commits (and we move down), Escape cancels,
1933
- // Tab commits (and the browser moves focus).
1934
2509
  scope.on(cellEl, "keydown", (ev) => {
1935
2510
  if (cellEl.getAttribute("contenteditable") !== "true") return;
1936
2511
  if (ev.key === "Escape") {
1937
2512
  ev.preventDefault();
1938
2513
  ev.stopPropagation();
1939
2514
  cancelEdit();
1940
- // Restore focus to the root so keyboard nav continues.
1941
2515
  root.focus();
1942
2516
  } else if (ev.key === "Enter" && !ev.shiftKey) {
1943
2517
  ev.preventDefault();
1944
2518
  ev.stopPropagation();
1945
2519
  commitEdit();
1946
2520
  root.focus();
1947
- // Move focus to the row below if there is one.
1948
2521
  moveFocus("down");
1949
2522
  } else if (ev.key === "Tab") {
1950
2523
  ev.preventDefault();
@@ -1955,28 +2528,19 @@ export function mountTable(host, table, options) {
1955
2528
  }
1956
2529
  });
1957
2530
 
1958
- // blur: commit. Use focusout so it fires reliably across all
1959
- // browsers and bubbles through the cell.
1960
2531
  scope.on(cellEl, "blur", () => {
1961
2532
  if (cellEl.getAttribute("contenteditable") !== "true") return;
1962
2533
  commitEdit();
1963
2534
  });
1964
2535
 
1965
- // dblclick: start editing this cell.
1966
2536
  scope.on(cellEl, "dblclick", (ev) => {
1967
- const i = slotIndex.peek();
1968
- const rs = visibleRows.peek();
1969
- if (i < 0 || i >= rs.length) return;
1970
- const row = rs[i];
1971
- if (row == null) return;
2537
+ const entry = slotEntry.peek();
2538
+ if (entry === null || entry.type !== "data") return;
1972
2539
  ev.preventDefault();
1973
- startEdit(getRowId(row), col.key);
2540
+ startEdit(getRowId(entry.row), col.key);
1974
2541
  });
1975
2542
  }
1976
2543
 
1977
- // No pointerdown listener here -- the root has one delegated
1978
- // listener that uses closest('.lt-cell') + data-key + slot index.
1979
-
1980
2544
  rowEl.appendChild(cellEl);
1981
2545
  }
1982
2546
 
@@ -1991,6 +2555,254 @@ export function mountTable(host, table, options) {
1991
2555
  while (slots.length < want) slots.push(buildSlot(slots.length));
1992
2556
  });
1993
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
+
1994
2806
  // ----- Delegated pointerdown on root ------------------------------------
1995
2807
  // One listener instead of pool-size x columns. We use closest('.lt-cell')
1996
2808
  // to find the tapped cell, read its data-key for the column, and find the
@@ -2007,17 +2819,31 @@ export function mountTable(host, table, options) {
2007
2819
  const poolIdx = slots.indexOf(rowEl);
2008
2820
  if (poolIdx < 0) return;
2009
2821
  const slotIdx = untrack(() => axis.start()) + poolIdx;
2010
- const rs = untrack(() => visibleRows());
2011
- if (slotIdx < 0 || slotIdx >= rs.length) return;
2012
- const row = rs[slotIdx];
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;
2013
2841
  if (row == null) return;
2014
2842
  const rowId = getRowId(row);
2015
2843
  if (ev.shiftKey) selectRow(rowId, "range");
2016
2844
  else if (ev.ctrlKey || ev.metaKey) selectRow(rowId, "toggle");
2017
2845
  else selectRow(rowId, "set");
2018
2846
  focusedCell.set({ rowId, columnKey: colKey });
2019
- // No preventDefault -- preserves native text selection on mouse and
2020
- // scroll initiation on touch.
2021
2847
  });
2022
2848
 
2023
2849
  // ----- aria-activedescendant --------------------------------------------