@gridkitjs/core 0.7.0 → 0.9.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/dist/index.js CHANGED
@@ -77,7 +77,10 @@ function buildKeyShortcuts(capabilities) {
77
77
  // handle to size the column to its content — same gate as the nudge
78
78
  // shortcuts, since both are resize actions.
79
79
  capabilities.resizable ? "Alt+ArrowLeft Alt+ArrowRight Alt+Enter" : "",
80
- capabilities.sortable ? "Alt+ArrowUp" : ""
80
+ capabilities.sortable ? "Alt+ArrowUp" : "",
81
+ // Down rather than reusing Up: it reads as "drill into groups", and Up is
82
+ // already sort's own mnemonic on the same header.
83
+ capabilities.groupable ? "Alt+ArrowDown" : ""
81
84
  ].filter(Boolean).join(" ");
82
85
  }
83
86
  function accessDotted(obj, path) {
@@ -95,7 +98,21 @@ function clampFocus(focus, rowCount, columnCount) {
95
98
  )
96
99
  };
97
100
  }
98
- function nextFocusForKey(key, modifiers, focus, rowCount, columnCount, pageSize) {
101
+ function noRowsSkippable() {
102
+ return false;
103
+ }
104
+ function skipSkippableRows(rowIndex, direction, rowCount, isSkippableRow, fallback) {
105
+ let next = rowIndex;
106
+ while (next >= 0 && next < rowCount && isSkippableRow(next)) {
107
+ const stepped = next + direction;
108
+ if (stepped < HEADER_ROW || stepped >= rowCount) {
109
+ return fallback;
110
+ }
111
+ next = stepped;
112
+ }
113
+ return next;
114
+ }
115
+ function nextFocusForKey(key, modifiers, focus, rowCount, columnCount, pageSize, isSkippableRow = noRowsSkippable) {
99
116
  if (modifiers.altKey) {
100
117
  return null;
101
118
  }
@@ -108,19 +125,64 @@ function nextFocusForKey(key, modifiers, focus, rowCount, columnCount, pageSize)
108
125
  if (modifiers.ctrlKey) return null;
109
126
  return { rowIndex, columnIndex: columnIndex + 1 };
110
127
  case "ArrowUp":
111
- return { rowIndex: rowIndex - 1, columnIndex };
128
+ return {
129
+ rowIndex: skipSkippableRows(
130
+ rowIndex - 1,
131
+ -1,
132
+ rowCount,
133
+ isSkippableRow,
134
+ rowIndex
135
+ ),
136
+ columnIndex
137
+ };
112
138
  case "ArrowDown":
113
- return { rowIndex: rowIndex + 1, columnIndex };
139
+ return {
140
+ rowIndex: skipSkippableRows(
141
+ rowIndex + 1,
142
+ 1,
143
+ rowCount,
144
+ isSkippableRow,
145
+ rowIndex
146
+ ),
147
+ columnIndex
148
+ };
114
149
  // Ctrl takes Home and End to the grid's ends rather than the row's, which
115
150
  // is the one place Ctrl is navigation rather than reorder.
116
151
  case "Home":
117
152
  return modifiers.ctrlKey ? { rowIndex: HEADER_ROW, columnIndex: 0 } : { rowIndex, columnIndex: 0 };
118
153
  case "End":
119
- return modifiers.ctrlKey ? { rowIndex: rowCount - 1, columnIndex: columnCount - 1 } : { rowIndex, columnIndex: columnCount - 1 };
154
+ return modifiers.ctrlKey ? {
155
+ rowIndex: skipSkippableRows(
156
+ rowCount - 1,
157
+ -1,
158
+ rowCount,
159
+ isSkippableRow,
160
+ rowIndex
161
+ ),
162
+ columnIndex: columnCount - 1
163
+ } : { rowIndex, columnIndex: columnCount - 1 };
120
164
  case "PageUp":
121
- return { rowIndex: rowIndex - pageSize, columnIndex };
165
+ return {
166
+ rowIndex: skipSkippableRows(
167
+ rowIndex - pageSize,
168
+ -1,
169
+ rowCount,
170
+ isSkippableRow,
171
+ rowIndex
172
+ ),
173
+ columnIndex
174
+ };
122
175
  case "PageDown":
123
- return { rowIndex: rowIndex + pageSize, columnIndex };
176
+ return {
177
+ rowIndex: skipSkippableRows(
178
+ rowIndex + pageSize,
179
+ 1,
180
+ rowCount,
181
+ isSkippableRow,
182
+ rowIndex
183
+ ),
184
+ columnIndex
185
+ };
124
186
  default:
125
187
  return null;
126
188
  }
@@ -302,6 +364,7 @@ function resolveColumnWidths(columns, sizing, options) {
302
364
  label: resolveColumnLabel(column),
303
365
  resizable: column.resizable ?? options?.resizable ?? false,
304
366
  reorderable: column.reorderable ?? options?.reorderable ?? false,
367
+ groupByDraggable: column.groupByDraggable ?? options?.groupByDraggable ?? false,
305
368
  alignment: column.alignment ?? alignmentForType(column.type ?? "string")
306
369
  };
307
370
  });
@@ -655,6 +718,137 @@ function filterRows(rows, filter, columns) {
655
718
  );
656
719
  }
657
720
 
721
+ // src/util/grouping.ts
722
+ function groupRowId(path) {
723
+ return JSON.stringify(path);
724
+ }
725
+ function bucketKey(value) {
726
+ if (value === null || value === void 0) {
727
+ return "\0empty";
728
+ }
729
+ if (value instanceof Date) {
730
+ return `\0date:${String(value.getTime())}`;
731
+ }
732
+ switch (typeof value) {
733
+ case "string":
734
+ case "number":
735
+ case "boolean":
736
+ case "bigint":
737
+ case "symbol":
738
+ return `\0${typeof value}:${String(value)}`;
739
+ case "function":
740
+ return "\0function";
741
+ default:
742
+ return `\0object:${JSON.stringify(value)}`;
743
+ }
744
+ }
745
+ function partitionByValue(rows, column, direction) {
746
+ const buckets = /* @__PURE__ */ new Map();
747
+ for (const row of rows) {
748
+ const value = accessDotted(row.row, column.field);
749
+ const key = bucketKey(value);
750
+ const bucket = buckets.get(key);
751
+ if (bucket === void 0) {
752
+ buckets.set(key, { value, rows: [row] });
753
+ } else {
754
+ bucket.rows.push(row);
755
+ }
756
+ }
757
+ const groups = [...buckets.values()];
758
+ groups.sort((a, b) => {
759
+ const result = compareValues(a.value, b.value, column.type);
760
+ return direction === "asc" ? result : -result;
761
+ });
762
+ return groups;
763
+ }
764
+ function buildLevel(rows, levels, parentPath, expansion, byId, out) {
765
+ const level = levels[0];
766
+ if (level === void 0) {
767
+ out.push(...rows);
768
+ return;
769
+ }
770
+ const column = byId.get(level.columnId);
771
+ if (column === void 0) {
772
+ out.push(...rows);
773
+ return;
774
+ }
775
+ const rest = levels.slice(1);
776
+ const groups = partitionByValue(rows, column, level.direction ?? "asc");
777
+ for (const group of groups) {
778
+ const path = [...parentPath, group.value];
779
+ const groupId = groupRowId(path);
780
+ const expanded = !expansion.includes(groupId);
781
+ const header = {
782
+ kind: "group",
783
+ groupId,
784
+ level: parentPath.length,
785
+ columnId: level.columnId,
786
+ value: group.value,
787
+ path,
788
+ expanded,
789
+ count: group.rows.length,
790
+ // Renumbered globally once `groupRows` has the whole flattened array —
791
+ // a header has no row of its own to carry a dataset position, so
792
+ // `datasetIndex` takes the same renumbered value `rowIndex` does.
793
+ rowIndex: -1,
794
+ datasetIndex: -1,
795
+ // Set by `withGroupAggregates`, downstream of grouping — empty here
796
+ // since `groupRows` itself never knows about `AggregateState`.
797
+ aggregates: /* @__PURE__ */ new Map()
798
+ };
799
+ out.push(header);
800
+ if (expanded) {
801
+ buildLevel(group.rows, rest, path, expansion, byId, out);
802
+ }
803
+ }
804
+ }
805
+ function groupRows(rows, groupBy, expansion, columns) {
806
+ if (groupBy.length === 0) {
807
+ return rows;
808
+ }
809
+ const byId = new Map(columns.map((entry) => [entry.id, entry.column]));
810
+ const active = groupBy.filter((entry) => byId.has(entry.columnId));
811
+ if (active.length === 0) {
812
+ return rows;
813
+ }
814
+ const out = [];
815
+ buildLevel(rows, active, [], expansion, byId, out);
816
+ return out.map((entry, index) => {
817
+ if ("kind" in entry) {
818
+ return entry.rowIndex === index && entry.datasetIndex === index ? entry : { ...entry, rowIndex: index, datasetIndex: index };
819
+ }
820
+ return entry.rowIndex === index ? entry : { ...entry, rowIndex: index };
821
+ });
822
+ }
823
+ function toggleGroupExpansion(expansion, groupId) {
824
+ return expansion.includes(groupId) ? expansion.filter((id) => id !== groupId) : [...expansion, groupId];
825
+ }
826
+ function expandAllGroups(expansion) {
827
+ return expansion.length === 0 ? expansion : [];
828
+ }
829
+ function collapseAllGroups(rows) {
830
+ return rows.filter((entry) => "kind" in entry).map((entry) => entry.groupId);
831
+ }
832
+ function moveGroupByBefore(groupBy, columnId, beforeColumnId) {
833
+ if (columnId === beforeColumnId) {
834
+ return groupBy;
835
+ }
836
+ const without = groupBy.filter((entry) => entry.columnId !== columnId);
837
+ const moved = groupBy.find((entry) => entry.columnId === columnId) ?? {
838
+ columnId
839
+ };
840
+ const target = beforeColumnId === null ? without.length : without.findIndex((entry) => entry.columnId === beforeColumnId);
841
+ if (target === -1) {
842
+ return groupBy;
843
+ }
844
+ const next = [...without];
845
+ next.splice(target, 0, moved);
846
+ return next.length === groupBy.length && next.every((entry, index) => entry === groupBy[index]) ? groupBy : next;
847
+ }
848
+ function movesGroupBy(groupBy, columnId, beforeColumnId) {
849
+ return moveGroupByBefore(groupBy, columnId, beforeColumnId) !== groupBy;
850
+ }
851
+
658
852
  // src/util/rows.ts
659
853
  function resolveShownRows(rows, filter, sort, columns) {
660
854
  return sortRows(filterRows(rows, filter, columns), sort, columns);
@@ -691,6 +885,262 @@ function resolveCell(rowsById, columns, cell) {
691
885
  value: accessDotted(entry.row, column.column.field)
692
886
  };
693
887
  }
888
+
889
+ // src/util/paging.ts
890
+ function unitsOf(rows) {
891
+ const units = [];
892
+ let start = 0;
893
+ let openedByGroup = false;
894
+ for (let index = 0; index < rows.length; index++) {
895
+ const entry = rows[index];
896
+ if (entry === void 0) {
897
+ continue;
898
+ }
899
+ const isTopLevelHeader = "kind" in entry && entry.kind === "group" && entry.level === 0;
900
+ const isBoundary = index !== start && (isTopLevelHeader || !openedByGroup);
901
+ if (isBoundary) {
902
+ units.push({ start, end: index });
903
+ start = index;
904
+ openedByGroup = isTopLevelHeader;
905
+ } else if (index === start) {
906
+ openedByGroup = isTopLevelHeader;
907
+ }
908
+ }
909
+ if (start < rows.length) {
910
+ units.push({ start, end: rows.length });
911
+ }
912
+ return units;
913
+ }
914
+ function paginateRows(rows, pagination) {
915
+ const pageSize = Math.max(1, pagination.pageSize);
916
+ const units = unitsOf(rows);
917
+ const pageCount = Math.ceil(units.length / pageSize);
918
+ if (pageCount === 0) {
919
+ return { rows: [], pageCount: 0, pageIndex: 0 };
920
+ }
921
+ const pageIndex = Math.min(Math.max(pagination.pageIndex, 0), pageCount - 1);
922
+ const firstUnit = units[pageIndex * pageSize];
923
+ const lastUnit = units[Math.min(pageIndex * pageSize + pageSize, units.length) - 1];
924
+ if (firstUnit === void 0 || lastUnit === void 0) {
925
+ return { rows: [], pageCount, pageIndex };
926
+ }
927
+ const page = rows.slice(firstUnit.start, lastUnit.end);
928
+ const renumbered = page.map(
929
+ (entry, index) => entry.rowIndex === index ? entry : { ...entry, rowIndex: index }
930
+ );
931
+ return { rows: renumbered, pageCount, pageIndex };
932
+ }
933
+
934
+ // src/util/paginationWindow.ts
935
+ function paginationWindow(currentPage, pageCount, options) {
936
+ if (pageCount <= 0) {
937
+ return [];
938
+ }
939
+ const boundaryCount = Math.max(0, options?.boundaryCount ?? 1);
940
+ const siblingCount = Math.max(0, options?.siblingCount ?? 1);
941
+ const current = Math.min(Math.max(currentPage, 1), pageCount);
942
+ const pages = /* @__PURE__ */ new Set();
943
+ for (let page = 1; page <= Math.min(boundaryCount, pageCount); page++) {
944
+ pages.add(page);
945
+ }
946
+ for (let page = Math.max(pageCount - boundaryCount + 1, 1); page <= pageCount; page++) {
947
+ pages.add(page);
948
+ }
949
+ for (let page = Math.max(current - siblingCount, 1); page <= Math.min(current + siblingCount, pageCount); page++) {
950
+ pages.add(page);
951
+ }
952
+ const sorted = Array.from(pages).sort((a, b) => a - b);
953
+ const entries = [];
954
+ let previous;
955
+ for (const page of sorted) {
956
+ if (previous !== void 0) {
957
+ if (page - previous === 2) {
958
+ entries.push(previous + 1);
959
+ } else if (page - previous > 2) {
960
+ entries.push("ellipsis");
961
+ }
962
+ }
963
+ entries.push(page);
964
+ previous = page;
965
+ }
966
+ return entries;
967
+ }
968
+
969
+ // src/util/aggregation.ts
970
+ function isEmpty2(value) {
971
+ return value === null || value === void 0;
972
+ }
973
+ function toNumber(value) {
974
+ return Number(value);
975
+ }
976
+ function distinctKey(value) {
977
+ if (isEmpty2(value)) {
978
+ return "\0empty";
979
+ }
980
+ if (value instanceof Date) {
981
+ return `\0date:${String(value.getTime())}`;
982
+ }
983
+ switch (typeof value) {
984
+ case "string":
985
+ case "number":
986
+ case "boolean":
987
+ case "bigint":
988
+ case "symbol":
989
+ return `\0${typeof value}:${String(value)}`;
990
+ case "function":
991
+ return "\0function";
992
+ default:
993
+ return `\0object:${JSON.stringify(value)}`;
994
+ }
995
+ }
996
+ function reduceBuiltIn(rows, fn, column) {
997
+ if (fn === "count") {
998
+ return rows.length;
999
+ }
1000
+ const field = column?.column.field;
1001
+ const values = field === void 0 ? [] : rows.map((row) => accessDotted(row, field));
1002
+ switch (fn) {
1003
+ case "sum": {
1004
+ let total = 0;
1005
+ for (const value of values) {
1006
+ if (isEmpty2(value)) continue;
1007
+ total += toNumber(value);
1008
+ }
1009
+ return total;
1010
+ }
1011
+ case "avg": {
1012
+ const present = values.filter((value) => !isEmpty2(value));
1013
+ if (present.length === 0) return void 0;
1014
+ const total = present.reduce(
1015
+ (acc, value) => acc + toNumber(value),
1016
+ 0
1017
+ );
1018
+ return total / present.length;
1019
+ }
1020
+ case "min":
1021
+ case "max": {
1022
+ const type = column?.column.type;
1023
+ let result;
1024
+ for (const value of values) {
1025
+ if (isEmpty2(value)) continue;
1026
+ if (result === void 0) {
1027
+ result = value;
1028
+ continue;
1029
+ }
1030
+ const comparison = compareValues(value, result, type);
1031
+ if (fn === "min" ? comparison < 0 : comparison > 0) {
1032
+ result = value;
1033
+ }
1034
+ }
1035
+ return result;
1036
+ }
1037
+ case "countDistinct": {
1038
+ const seen = /* @__PURE__ */ new Set();
1039
+ for (const value of values) {
1040
+ if (isEmpty2(value)) continue;
1041
+ seen.add(distinctKey(value));
1042
+ }
1043
+ return seen.size;
1044
+ }
1045
+ }
1046
+ }
1047
+ function keyOf(spec) {
1048
+ return spec.id ?? spec.columnId;
1049
+ }
1050
+ function computeAggregates(rows, specs, columns) {
1051
+ const results = /* @__PURE__ */ new Map();
1052
+ if (specs.length === 0) {
1053
+ return results;
1054
+ }
1055
+ const byId = new Map(columns.map((entry) => [entry.id, entry]));
1056
+ for (const spec of specs) {
1057
+ const value = typeof spec.fn === "function" ? spec.fn(rows) : reduceBuiltIn(rows, spec.fn, byId.get(spec.columnId));
1058
+ results.set(keyOf(spec), value);
1059
+ }
1060
+ return results;
1061
+ }
1062
+ function leafDescendantsOf(flatRows, path, groupBy, byId) {
1063
+ const keys = path.map((value) => bucketKey(value));
1064
+ return flatRows.filter(
1065
+ (row) => keys.every((key, level) => {
1066
+ const columnId = groupBy[level]?.columnId;
1067
+ const column = columnId === void 0 ? void 0 : byId.get(columnId);
1068
+ if (column === void 0) return false;
1069
+ return bucketKey(accessDotted(row.row, column.field)) === key;
1070
+ })
1071
+ ).map((row) => row.row);
1072
+ }
1073
+ function appendAggregatedSpan(displayRows, start, levelFloor, flatRows, groupBy, byId, specs, columns, display, out) {
1074
+ let index = start;
1075
+ while (index < displayRows.length) {
1076
+ const entry = displayRows[index];
1077
+ if (entry === void 0) {
1078
+ index++;
1079
+ continue;
1080
+ }
1081
+ if (levelFloor !== null && "kind" in entry && entry.kind === "group" && entry.level <= levelFloor) {
1082
+ break;
1083
+ }
1084
+ index++;
1085
+ if (!("kind" in entry) || entry.kind !== "group") {
1086
+ out.push(entry);
1087
+ continue;
1088
+ }
1089
+ const leaves = leafDescendantsOf(flatRows, entry.path, groupBy, byId);
1090
+ const aggregates = computeAggregates(leaves, specs, columns);
1091
+ const withAggregates = { ...entry, aggregates };
1092
+ out.push(withAggregates);
1093
+ index = appendAggregatedSpan(
1094
+ displayRows,
1095
+ index,
1096
+ entry.level,
1097
+ flatRows,
1098
+ groupBy,
1099
+ byId,
1100
+ specs,
1101
+ columns,
1102
+ display,
1103
+ out
1104
+ );
1105
+ if (display === "row") {
1106
+ const summary = {
1107
+ kind: "group-summary",
1108
+ groupId: entry.groupId,
1109
+ level: entry.level,
1110
+ rowIndex: -1,
1111
+ datasetIndex: -1,
1112
+ aggregates
1113
+ };
1114
+ out.push(summary);
1115
+ }
1116
+ }
1117
+ return index;
1118
+ }
1119
+ function withGroupAggregates(displayRows, flatRows, groupBy, specs, columns, display = "inline") {
1120
+ if (specs.length === 0) {
1121
+ return displayRows;
1122
+ }
1123
+ const byId = new Map(columns.map((entry) => [entry.id, entry.column]));
1124
+ const out = [];
1125
+ appendAggregatedSpan(
1126
+ displayRows,
1127
+ 0,
1128
+ null,
1129
+ flatRows,
1130
+ groupBy,
1131
+ byId,
1132
+ specs,
1133
+ columns,
1134
+ display,
1135
+ out
1136
+ );
1137
+ return out.map((entry, position) => {
1138
+ if ("kind" in entry) {
1139
+ return entry.rowIndex === position && entry.datasetIndex === position ? entry : { ...entry, rowIndex: position, datasetIndex: position };
1140
+ }
1141
+ return entry.rowIndex === position ? entry : { ...entry, rowIndex: position };
1142
+ });
1143
+ }
694
1144
  export {
695
1145
  DEFAULT_COLUMN_SIZES,
696
1146
  HEADER_ROW,
@@ -706,19 +1156,28 @@ export {
706
1156
  clampFocus,
707
1157
  clearAllFilters,
708
1158
  clearSelection,
1159
+ collapseAllGroups,
709
1160
  compareValues,
1161
+ computeAggregates,
710
1162
  defineColumnsFromRows,
711
1163
  diffSelection,
1164
+ expandAllGroups,
712
1165
  filterQueryFor,
713
1166
  filterRows,
714
1167
  fitColumnsToWidth,
715
1168
  getColumnId,
1169
+ groupRowId,
1170
+ groupRows,
716
1171
  intentOf,
717
1172
  isSameCell,
718
1173
  matchesQuery,
719
1174
  moveColumnBefore,
1175
+ moveGroupByBefore,
720
1176
  movesColumn,
1177
+ movesGroupBy,
721
1178
  nextFocusForKey,
1179
+ paginateRows,
1180
+ paginationWindow,
722
1181
  resolveCell,
723
1182
  resolveColumnConstraints,
724
1183
  resolveColumnLabel,
@@ -741,7 +1200,9 @@ export {
741
1200
  sortRows,
742
1201
  toggleCellSelection,
743
1202
  toggleColumnSort,
1203
+ toggleGroupExpansion,
744
1204
  toggleSelection,
745
- totalColumnWidth
1205
+ totalColumnWidth,
1206
+ withGroupAggregates
746
1207
  };
747
1208
  //# sourceMappingURL=index.js.map