@svgrid/grid 2.2.31 → 2.2.33

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.
Files changed (196) hide show
  1. package/README.md +1 -1
  2. package/dist/DockNodeView.svelte +1 -1
  3. package/dist/GridFooter.svelte +0 -40
  4. package/dist/GridMenus.svelte +0 -77
  5. package/dist/SvAvatar.svelte +1 -1
  6. package/dist/SvCheckBox.svelte +2 -1
  7. package/dist/SvCircularProgress.svelte +1 -1
  8. package/dist/SvDateRangeInput.svelte +1 -1
  9. package/dist/SvGrid.controller.svelte.d.ts +20 -0
  10. package/dist/SvGrid.controller.svelte.js +358 -18
  11. package/dist/SvGrid.css +103 -3
  12. package/dist/SvGrid.svelte +164 -35
  13. package/dist/SvGrid.types.d.ts +81 -0
  14. package/dist/SvGridChart.svelte +2 -10
  15. package/dist/SvGridDropdown.svelte +38 -6
  16. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  17. package/dist/SvGridSelect.svelte +12 -5
  18. package/dist/SvGridSelect.svelte.d.ts +6 -0
  19. package/dist/SvGroupCell.svelte +9 -2
  20. package/dist/SvKbd.svelte +1 -1
  21. package/dist/SvList.svelte +1 -1
  22. package/dist/SvMenubar.svelte +1 -1
  23. package/dist/SvMultiSelect.svelte +12 -4
  24. package/dist/SvMultiSelect.svelte.d.ts +6 -0
  25. package/dist/SvOtpInput.svelte +1 -1
  26. package/dist/SvPasswordInput.svelte +1 -1
  27. package/dist/SvProgress.svelte +1 -1
  28. package/dist/SvRating.svelte +1 -1
  29. package/dist/SvResult.svelte +1 -1
  30. package/dist/SvRichText.svelte +35 -16
  31. package/dist/SvRichText.svelte.d.ts +9 -0
  32. package/dist/SvSwitchButton.svelte +2 -1
  33. package/dist/SvToaster.svelte +1 -1
  34. package/dist/SvTour.svelte +4 -0
  35. package/dist/SvTree.svelte +9 -2
  36. package/dist/SvTree.svelte.d.ts +5 -0
  37. package/dist/SvTreeSelect.svelte +6 -4
  38. package/dist/build-api.js +16 -0
  39. package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-B7XhhqdN.js} +34 -36
  40. package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-BxmIvCJh.js} +35 -37
  41. package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
  42. package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-o94wfSUa.js} +3 -2
  43. package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Cr_90pPD.js} +1 -1
  44. package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-ICbxiUvK.js} +4 -4
  45. package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-Bx42PMvf.js} +2 -2
  46. package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CUOdm7YA.js} +93 -88
  47. package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
  48. package/dist/cdn/{disclose-version-DoD9AFD_.js → disclose-version-DljhZohK.js} +45 -45
  49. package/dist/cdn/{src-DYDCiC0D.js → src-erjBRYfX.js} +5952 -5615
  50. package/dist/cdn/{src-BLJfdyL0.js → src-ktbQVOk-.js} +4190 -3851
  51. package/dist/cdn/svgrid.js +9 -9
  52. package/dist/cdn/svgrid.svelte-external.js +8 -8
  53. package/dist/cell-render.d.ts +1 -0
  54. package/dist/cell-render.js +115 -1
  55. package/dist/cell-values.js +5 -0
  56. package/dist/chart.d.ts +2 -0
  57. package/dist/chart.js +1 -4
  58. package/dist/core.d.ts +80 -9
  59. package/dist/core.js +89 -0
  60. package/dist/createMenu.svelte.js +0 -1
  61. package/dist/createPopoverSelect.svelte.d.ts +2 -2
  62. package/dist/editing.js +1 -1
  63. package/dist/editor-contract.d.ts +12 -5
  64. package/dist/editor-registry.d.ts +18 -7
  65. package/dist/editor-registry.js +10 -1
  66. package/dist/export-format.js +3 -0
  67. package/dist/group-display.d.ts +39 -0
  68. package/dist/group-display.js +52 -0
  69. package/dist/index.d.ts +2 -2
  70. package/dist/index.js +2 -2
  71. package/dist/keyboard-handlers.js +16 -0
  72. package/dist/recurrence.js +3 -2
  73. package/dist/selection.js +0 -1
  74. package/dist/svgrid-wrapper.types.d.ts +8 -2
  75. package/dist/themes/index.d.ts +10 -0
  76. package/dist/themes/index.js +26 -0
  77. package/package.json +3 -2
  78. package/src/DockNodeView.svelte +1 -1
  79. package/src/GridFooter.svelte +0 -40
  80. package/src/GridMenus.svelte +0 -77
  81. package/src/SvAccordion.test.ts +5 -5
  82. package/src/SvAvatar.svelte +1 -1
  83. package/src/SvButtonGroup.test.ts +6 -6
  84. package/src/SvCalendar.test.ts +1 -1
  85. package/src/SvCheckBox.svelte +2 -1
  86. package/src/SvCircularProgress.svelte +1 -1
  87. package/src/SvDateRangeInput.svelte +1 -1
  88. package/src/SvFileUpload.test.ts +1 -1
  89. package/src/SvGrid.controller.svelte.ts +367 -22
  90. package/src/SvGrid.css +103 -3
  91. package/src/SvGrid.svelte +164 -35
  92. package/src/SvGrid.types.ts +81 -1
  93. package/src/SvGridChart.svelte +2 -10
  94. package/src/SvGridDropdown.svelte +38 -6
  95. package/src/SvGridSelect.svelte +12 -5
  96. package/src/SvGroupCell.svelte +9 -2
  97. package/src/SvKbd.svelte +1 -1
  98. package/src/SvList.svelte +1 -1
  99. package/src/SvMenubar.svelte +1 -1
  100. package/src/SvMultiSelect.svelte +12 -4
  101. package/src/SvOtpInput.svelte +1 -1
  102. package/src/SvPasswordInput.svelte +1 -1
  103. package/src/SvProgress.svelte +1 -1
  104. package/src/SvRating.svelte +1 -1
  105. package/src/SvResult.svelte +1 -1
  106. package/src/SvRichText.svelte +35 -16
  107. package/src/SvSwitchButton.svelte +2 -1
  108. package/src/SvToaster.svelte +1 -1
  109. package/src/SvTour.svelte +4 -0
  110. package/src/SvTree.svelte +9 -2
  111. package/src/SvTreeSelect.svelte +6 -4
  112. package/src/ai.test.ts +1 -1
  113. package/src/build-api.ts +15 -105
  114. package/src/builtin-editors.test.ts +3 -1
  115. package/src/cell-formatting.test.ts +1 -1
  116. package/src/cell-render.test.ts +27 -3
  117. package/src/cell-render.ts +122 -95
  118. package/src/cell-values.ts +4 -0
  119. package/src/chart-export.test.ts +2 -2
  120. package/src/chart.ts +1 -2
  121. package/src/clipboard.test.ts +0 -2
  122. package/src/clipboard.ts +0 -102
  123. package/src/columns.ts +0 -115
  124. package/src/core.ts +157 -8
  125. package/src/createMenu.svelte.ts +0 -1
  126. package/src/editing.test.ts +1 -1
  127. package/src/editing.ts +2 -105
  128. package/src/editor-contract.ts +12 -5
  129. package/src/editor-registry.grid.test.ts +48 -2
  130. package/src/editor-registry.test.ts +3 -1
  131. package/src/editor-registry.ts +23 -7
  132. package/src/export-format.ts +3 -0
  133. package/src/facet-buckets.test.ts +33 -33
  134. package/src/features.ts +0 -90
  135. package/src/group-display.test.ts +91 -1
  136. package/src/group-display.ts +83 -0
  137. package/src/index.ts +9 -0
  138. package/src/keyboard-handlers.coverage.test.ts +1 -1
  139. package/src/keyboard-handlers.ts +17 -120
  140. package/src/list-power.test.ts +2 -2
  141. package/src/menus.test.ts +4 -5
  142. package/src/menus.ts +0 -112
  143. package/src/named-views.coverage.test.ts +3 -3
  144. package/src/new-features.test.ts +3 -1
  145. package/src/recurrence.ts +3 -2
  146. package/src/row-drag.test.ts +1 -1
  147. package/src/row-resize.test.ts +41 -42
  148. package/src/scheduler-model.ts +1 -1
  149. package/src/scroll-sync.test.ts +4 -7
  150. package/src/scroll-sync.ts +0 -122
  151. package/src/selection.ts +0 -109
  152. package/src/server-data-source.coverage.test.ts +1 -1
  153. package/src/server-group-model.test.ts +8 -8
  154. package/src/spreadsheet.test.ts +0 -1
  155. package/src/summaries.ts +0 -112
  156. package/src/svgrid-wrapper.types.ts +11 -9
  157. package/src/svgrid.async-editor-options.test.ts +271 -0
  158. package/src/svgrid.auto-row-height.test.ts +204 -0
  159. package/src/svgrid.behavior.test.ts +0 -1
  160. package/src/svgrid.charting.test.ts +4 -4
  161. package/src/svgrid.column-reorder.test.ts +0 -8
  162. package/src/svgrid.comments-autocomplete.test.ts +8 -2
  163. package/src/svgrid.context-menu.test.ts +25 -9
  164. package/src/svgrid.filter-depth.test.ts +163 -0
  165. package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
  166. package/src/svgrid.filter-menu-scroll.test.ts +10 -2
  167. package/src/svgrid.grand-total.test.ts +188 -0
  168. package/src/svgrid.group-display-mode.test.ts +171 -0
  169. package/src/svgrid.group-footers.test.ts +121 -0
  170. package/src/svgrid.group-pagination.test.ts +153 -0
  171. package/src/svgrid.tree-data.test.ts +186 -0
  172. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
  173. package/src/themes/index.ts +42 -0
  174. package/src/tree-edit.test.ts +4 -4
  175. package/src/tree-row-model.test.ts +168 -0
  176. package/src/ui-localization.test.ts +113 -0
  177. package/themes/ag-alpine.css +22 -0
  178. package/themes/antd.css +22 -0
  179. package/themes/atlassian.css +22 -0
  180. package/themes/bootstrap.css +22 -0
  181. package/themes/carbon.css +22 -0
  182. package/themes/catppuccin.css +22 -0
  183. package/themes/dracula.css +22 -0
  184. package/themes/ember.css +22 -0
  185. package/themes/excel.css +22 -0
  186. package/themes/fluent.css +22 -0
  187. package/themes/github.css +22 -0
  188. package/themes/linear.css +22 -0
  189. package/themes/material.css +22 -0
  190. package/themes/nord.css +22 -0
  191. package/themes/notion.css +22 -0
  192. package/themes/salesforce.css +22 -0
  193. package/themes/sap.css +22 -0
  194. package/themes/shadcn.css +22 -0
  195. package/themes/tailwind.css +22 -0
  196. package/themes/vercel.css +22 -0
@@ -1,20 +1,18 @@
1
1
  import {
2
2
  applyExcelFilter,
3
+ applyGroupAggregate,
3
4
  normalizeForFilter,
4
5
  createColumnVirtualizer,
5
6
  createCoreRowModel,
6
7
  createExpandedRowModel,
7
8
  createFilteredRowModel,
8
9
  createGroupedRowModel,
10
+ createTreeRowModel,
9
11
  createSvelteVirtualizer,
10
12
  createSortedRowModel,
11
13
  createSvGrid,
12
14
  getGridCellDomId,
13
15
  sortFns,
14
- tableFeatures,
15
- rowSortingFeature,
16
- columnFilteringFeature,
17
- columnGroupingFeature,
18
16
  type CellEditorOption,
19
17
  type Column,
20
18
  type ColumnDef,
@@ -36,7 +34,6 @@ import {
36
34
  formatNeedsStats,
37
35
  type ColumnStat,
38
36
  } from "./conditional-formatting";
39
- import SvGridDropdown from "./SvGridDropdown.svelte";
40
37
  import type {
41
38
  Props,
42
39
  SelectionRange,
@@ -88,6 +85,7 @@ import {
88
85
  hiddenLeavesForCollapse,
89
86
  } from "./column-groups";
90
87
  import { resolveColumnId } from "./column-id";
88
+ import { buildAutoGroupColumns, insertGroupFooters, paginateGroupedRows } from "./group-display";
91
89
  import {
92
90
  createGridApi,
93
91
  } from "./build-api";
@@ -667,6 +665,10 @@ export function createSvGridController<
667
665
  const externalPaginationEnabled = $derived(props.externalPagination === true);
668
666
  const passthroughSortedRowModel = ({ rows }: { rows: Array<Row<TData>> }) =>
669
667
  rows;
668
+ // Client tree data. Structural like external sort, so captured once at mount -
669
+ // the row-model pipeline is read before any later prop change could apply.
670
+ // svelte-ignore state_referenced_locally
671
+ const treeDataConfig = props.treeData;
670
672
 
671
673
 
672
674
  const grid = createSvGrid({
@@ -687,7 +689,14 @@ export function createSvGridController<
687
689
  sortedRowModel: externalSortEnabled
688
690
  ? passthroughSortedRowModel
689
691
  : createSortedRowModel<TData>(sortFns),
690
- groupedRowModel: createGroupedRowModel<TData>(),
692
+ // Tree nesting replaces grouping when `treeData` is set - a row cannot
693
+ // be both a hierarchy node and bucketed under a group banner.
694
+ groupedRowModel: treeDataConfig
695
+ ? createTreeRowModel<TData>({
696
+ parentField: treeDataConfig.parentField,
697
+ idField: treeDataConfig.idField,
698
+ })
699
+ : createGroupedRowModel<TData>(),
691
700
  expandedRowModel: createExpandedRowModel<TData>(),
692
701
  };
693
702
  },
@@ -771,6 +780,103 @@ export function createSvGridController<
771
780
  userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
772
781
  });
773
782
 
783
+ // Declared here rather than beside the other pagination/state derivations:
784
+ // the auto-group column pipeline below reads it, and a `const` used above its
785
+ // declaration is a type error even though `$derived` is lazy enough at runtime.
786
+ const groupingColumns = $derived.by(() => {
787
+ gridStateVersion;
788
+ return grid.getState().grouping ?? [];
789
+ });
790
+
791
+ /**
792
+ * Row-grouping display mode. `groupRows` (the default) renders a full-width
793
+ * banner per group - the historical behaviour. `singleColumn` folds every
794
+ * grouping level into one synthetic column; `multipleColumns` gives each
795
+ * grouped field its own. Both hide the grouped SOURCE columns, since their
796
+ * values now live in the auto column.
797
+ */
798
+ const groupDisplayMode = $derived(props.groupDisplayMode ?? "groupRows");
799
+ const autoGroupSpec = $derived(
800
+ buildAutoGroupColumns(groupingColumns, groupDisplayMode),
801
+ );
802
+ /** True when group state is shown in columns rather than banner rows. */
803
+ const groupColumnMode = $derived(autoGroupSpec.autoColumns.length > 0);
804
+
805
+ /**
806
+ * The synthetic auto-group columns as real `Column` objects, so the rest of
807
+ * the pipeline (widths, pinning, virtualization, headers) treats them like
808
+ * any other column. They carry no `field` - their cell content is resolved
809
+ * from the row's group state at render time.
810
+ */
811
+ const autoGroupColumns = $derived.by(() => {
812
+ if (!groupColumnMode) return [] as Column<TData>[];
813
+ const sourceById = new Map(grid.getAllColumns().map((c) => [c.id, c]));
814
+ return autoGroupSpec.autoColumns.map((spec) => {
815
+ const source = spec.field ? sourceById.get(spec.field) : undefined;
816
+ const sourceHeader = source?.columnDef.header;
817
+ const header =
818
+ spec.field == null
819
+ ? (props.autoGroupColumnHeader ?? "Group")
820
+ : typeof sourceHeader === "string"
821
+ ? sourceHeader
822
+ : spec.field;
823
+ const column: Column<TData> = {
824
+ id: spec.id,
825
+ depth: 0,
826
+ columnDef: {
827
+ header,
828
+ width: props.autoGroupColumnWidth ?? 220,
829
+ sortable: false,
830
+ filterable: false,
831
+ } as never,
832
+ getCanSort: () => false,
833
+ getCanFilter: () => false,
834
+ getIsSorted: () => false,
835
+ getToggleSortingHandler: () => () => {},
836
+ };
837
+ return column;
838
+ });
839
+ });
840
+
841
+ /**
842
+ * What an auto-group column shows for a given row.
843
+ *
844
+ * `singleColumn` puts every level in one column, so a group row renders its
845
+ * own level (indented by depth) and leaf rows stay blank - the ancestry is
846
+ * already visible in the banner rows above.
847
+ *
848
+ * `multipleColumns` gives each grouped field a column, so a group row only
849
+ * writes into the column matching ITS level and leaves the others blank.
850
+ *
851
+ * Returns null when the cell should render nothing.
852
+ */
853
+ function autoGroupCell(
854
+ row: Row<TData>,
855
+ columnId: string,
856
+ ): { label: string; count: number; depth: number } | null {
857
+ if (!isGroupRow(row)) return null;
858
+ const depth = row.depth ?? 0;
859
+ if (columnId !== "__autoGroup") {
860
+ // multipleColumns: only the column for this row's own level renders.
861
+ const field = groupingColumns[depth];
862
+ if (!field || `__group_${field}` !== columnId) return null;
863
+ }
864
+ const groupingColumnId = groupingColumns[depth] ?? "";
865
+ const sourceColumn = grid
866
+ .getAllColumns()
867
+ .find((c) => c.id === groupingColumnId);
868
+ const raw = row.getCellValueByColumnId(groupingColumnId);
869
+ const label = sourceColumn
870
+ ? formatCellValue(sourceColumn, raw, row)
871
+ : String(raw ?? "");
872
+ return {
873
+ label,
874
+ count: row.leafCount ?? row.subRows?.length ?? 0,
875
+ // singleColumn indents by level; multipleColumns already separates them.
876
+ depth: columnId === "__autoGroup" ? depth : 0,
877
+ };
878
+ }
879
+
774
880
  const allColumns = $derived.by(() => {
775
881
  let raw = grid
776
882
  .getAllColumns()
@@ -778,8 +884,13 @@ export function createSvGridController<
778
884
  (column) =>
779
885
  !hiddenColumns[column.id] &&
780
886
  !hiddenByGroupCollapse[column.id] &&
781
- !isHiddenByResponsive(column),
887
+ !isHiddenByResponsive(column) &&
888
+ // In a column display mode the grouped columns are folded into the
889
+ // auto column(s), so showing them too would just duplicate the value.
890
+ !autoGroupSpec.hiddenSourceIds.has(column.id),
782
891
  );
892
+ // Auto-group columns lead, like a row header.
893
+ if (autoGroupColumns.length) raw = [...autoGroupColumns, ...raw];
783
894
  // Apply user reorder (if any). Unknown ids in userColumnOrder are
784
895
  // skipped; columns not in userColumnOrder keep their original
785
896
  // relative order after the user-ordered ones.
@@ -821,7 +932,21 @@ export function createSvGridController<
821
932
  const headers: (typeof base)[number]["headers"] = [];
822
933
  for (const column of allColumns) {
823
934
  const header = byId.get(column.id);
824
- if (header) headers.push(header);
935
+ if (header) {
936
+ headers.push(header);
937
+ continue;
938
+ }
939
+ // Auto-group columns have no engine header. Synthesize one rather than
940
+ // skipping: the header row indexes `headers` BY POSITION against the
941
+ // rendered columns, so a gap would shift every later header by one.
942
+ const synthetic = {
943
+ id: column.id,
944
+ isPlaceholder: false,
945
+ colSpan: 1,
946
+ column,
947
+ getContext: () => ({ header: synthetic, column, table: grid }),
948
+ } as (typeof base)[number]["headers"][number];
949
+ headers.push(synthetic);
825
950
  }
826
951
  return [{ id: base[0]!.id, headers }];
827
952
  });
@@ -1098,10 +1223,6 @@ export function createSvGridController<
1098
1223
  return directions;
1099
1224
  });
1100
1225
 
1101
- const groupingColumns = $derived.by(() => {
1102
- gridStateVersion;
1103
- return grid.getState().grouping ?? [];
1104
- });
1105
1226
 
1106
1227
  const paginationState = $derived.by(() => {
1107
1228
  gridStateVersion;
@@ -1221,22 +1342,137 @@ export function createSvGridController<
1221
1342
  * the full dataset rather than the current page (see the comment above
1222
1343
  * `_rowModels`).
1223
1344
  */
1224
- const allRows = $derived.by(() => {
1225
- const rows = allRowsBeforePagination;
1226
- // External pagination: `data` already IS the current page - never slice.
1227
- if (!paginationEnabled || externalPaginationEnabled) return rows;
1345
+ /**
1346
+ * Grouped pagination. `allRowsBeforePagination` interleaves group banners with
1347
+ * data rows, so a plain slice spends the page budget on banners - ten groups of
1348
+ * two at `pageSize: 10` used to show about three data rows (#73). When grouping
1349
+ * is active we page by DATA rows and reprint each page's ancestor banners.
1350
+ * Null when grouping is off, so the flat path stays a cheap slice.
1351
+ */
1352
+ const groupedPage = $derived.by(() => {
1353
+ if (!paginationEnabled || externalPaginationEnabled) return null;
1354
+ if (!groupingColumns.length) return null;
1228
1355
  const { pageIndex, pageSize } = paginationState;
1229
- const start = pageIndex * pageSize;
1230
- return rows.slice(start, start + pageSize);
1356
+ return paginateGroupedRows(allRowsBeforePagination, {
1357
+ getDepth: (row: any) => row.depth ?? 0,
1358
+ isGroup: (row: any) => isGroupRow(row),
1359
+ // A collapsed group is the visible unit and takes a page slot; an expanded
1360
+ // one is a banner reprinted above its children.
1361
+ isExpanded: (row: any) => row.getIsExpanded?.() === true,
1362
+ pageIndex,
1363
+ pageSize,
1364
+ });
1231
1365
  });
1232
1366
 
1367
+ /**
1368
+ * The grand-total row: one synthetic row carrying each column's `aggregate`
1369
+ * over the whole filtered set. Shaped like a group footer so it renders
1370
+ * through the normal cell path - totals land under the columns they belong
1371
+ * to, and `getCanExpand`/`getIsExpanded` are off so nothing treats it as a
1372
+ * banner. Returns null when no column aggregates (nothing to total).
1373
+ *
1374
+ * Aggregates the LEAF rows: `allRowsBeforePagination` interleaves group
1375
+ * banners once grouping is on, and those carry per-group subtotals that
1376
+ * would otherwise be summed a second time.
1377
+ */
1378
+ const buildGrandTotalRow = () => {
1379
+ const leaves = allRowsBeforePagination.filter((row: any) => !isGroupRow(row));
1380
+ const columns = grid.getAllColumns();
1381
+ const original: Record<string, unknown> = {};
1382
+ let hasAggregate = false;
1383
+ for (const column of columns) {
1384
+ const field = column.columnDef.field;
1385
+ const agg = column.columnDef.aggregate;
1386
+ if (!field || !agg) continue;
1387
+ hasAggregate = true;
1388
+ original[field] = applyGroupAggregate(agg, column.id, leaves as any);
1389
+ }
1390
+ if (!hasAggregate) return null;
1391
+ return {
1392
+ id: "__grand_total__",
1393
+ index: -1,
1394
+ original,
1395
+ depth: 0,
1396
+ subRows: [],
1397
+ leafCount: leaves.length,
1398
+ __groupFooter: true,
1399
+ __grandTotal: true,
1400
+ getCanExpand: () => false,
1401
+ getIsExpanded: () => false,
1402
+ toggleExpanded: () => {},
1403
+ getIsSelected: () => false,
1404
+ toggleSelected: () => {},
1405
+ getAllCells: () => [],
1406
+ getCellValueByColumnId: (columnId: string) => {
1407
+ const col = columns.find((c) => c.id === columnId);
1408
+ const field = col?.columnDef.field;
1409
+ return field && field in original ? original[field] : undefined;
1410
+ },
1411
+ } as any;
1412
+ };
1413
+
1414
+ /**
1415
+ * Visible rows for the current page. Applied last so filters operate on
1416
+ * the full dataset rather than the current page (see the comment above
1417
+ * `_rowModels`).
1418
+ */
1419
+ const allRows = $derived.by(() => {
1420
+ const paged = (() => {
1421
+ const rows = allRowsBeforePagination;
1422
+ // External pagination: `data` already IS the current page - never slice.
1423
+ if (!paginationEnabled || externalPaginationEnabled) return rows;
1424
+ if (groupedPage) return groupedPage.rows;
1425
+ const { pageIndex, pageSize } = paginationState;
1426
+ const start = pageIndex * pageSize;
1427
+ return rows.slice(start, start + pageSize);
1428
+ })();
1429
+ const wantGroupFooters = Boolean(props.groupFooters) && groupingColumns.length > 0;
1430
+ // The grand total belongs at the END of the dataset, so it is appended only
1431
+ // on the last page - otherwise every page would end in a "total" that isn't
1432
+ // one. With pagination off (or external, where the consumer owns paging and
1433
+ // `data` is already the page) there is only one page to be last.
1434
+ const isLastPage = (() => {
1435
+ if (!paginationEnabled || externalPaginationEnabled) return true;
1436
+ const { pageIndex, pageSize } = paginationState;
1437
+ const total = groupedPage ? groupedPage.dataRowCount : allRowsBeforePagination.length;
1438
+ return pageIndex >= Math.ceil(total / pageSize) - 1;
1439
+ })();
1440
+ const wantGrandTotal = Boolean(props.grandTotalRow) && isLastPage;
1441
+ if (!wantGroupFooters && !wantGrandTotal) return paged;
1442
+ // Footers are inserted AFTER paging so they never eat page budget. A footer
1443
+ // is a clone of its group row, which already carries the group's aggregate
1444
+ // values - so it renders through the normal cell path and the totals land
1445
+ // under the columns they belong to, with `getCanExpand` off so nothing
1446
+ // treats it as a banner.
1447
+ return insertGroupFooters(paged, {
1448
+ getDepth: (row: any) => row.depth ?? 0,
1449
+ isGroup: (row: any) => isGroupRow(row),
1450
+ includeGroupFooter: wantGroupFooters,
1451
+ includeGrandTotalFooter: wantGrandTotal,
1452
+ makeGrandTotal: buildGrandTotalRow,
1453
+ makeFooter: (group: any) => ({
1454
+ ...group,
1455
+ id: `${group.id}__footer`,
1456
+ subRows: [],
1457
+ __groupFooter: true,
1458
+ getCanExpand: () => false,
1459
+ getIsExpanded: () => false,
1460
+ }),
1461
+ }) as typeof paged;
1462
+ });
1463
+
1464
+ /** Rows the pager divides into pages: data rows only once grouping is on. */
1465
+ const paginatedRowTotal = $derived(
1466
+ groupedPage ? groupedPage.dataRowCount : allRowsBeforePagination.length,
1467
+ );
1468
+
1233
1469
  // When a filter reduces the dataset, the stored pageIndex can point beyond
1234
1470
  // the last valid page. Reset to page 0 so the grid never shows a blank body.
1235
1471
  // Skipped for external pagination, where the consumer owns pageIndex.
1236
1472
  $effect(() => {
1237
1473
  if (!paginationEnabled || externalPaginationEnabled) return;
1238
1474
  const { pageIndex, pageSize } = paginationState;
1239
- const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1475
+ const pageCount = Math.ceil(paginatedRowTotal / pageSize);
1240
1476
  if (pageCount > 0 && pageIndex >= pageCount) {
1241
1477
  grid.setPagination({ pageIndex: 0, pageSize });
1242
1478
  }
@@ -1245,7 +1481,7 @@ export function createSvGridController<
1245
1481
  // Footer-facing pagination values. In external mode they come from the
1246
1482
  // consumer-controlled props; otherwise from the local row model + state.
1247
1483
  const paginationTotalRows = $derived(
1248
- externalPaginationEnabled ? (props.rowCount ?? 0) : allRowsBeforePagination.length,
1484
+ externalPaginationEnabled ? (props.rowCount ?? 0) : paginatedRowTotal,
1249
1485
  );
1250
1486
  const paginationPageIndex = $derived(
1251
1487
  externalPaginationEnabled ? (props.pageIndex ?? 0) : paginationState.pageIndex,
@@ -2223,6 +2459,75 @@ export function createSvGridController<
2223
2459
  el.removeEventListener("scroll-change", onHorizontal as EventListener);
2224
2460
  });
2225
2461
 
2462
+ // --- Auto row height -------------------------------------------------
2463
+ // Measured natural heights, keyed by row index. A plain Map (not $state):
2464
+ // it is written from a ResizeObserver during layout, and making it reactive
2465
+ // would re-run the render that is doing the measuring. `autoRowHeightVersion`
2466
+ // is the reactive signal instead, bumped only when a height actually changes.
2467
+ const measuredRowHeights = new Map<number, number>();
2468
+ let autoRowHeightVersion = $state(0);
2469
+ // A per-row `rowHeight` function already supplies heights, so auto-measuring
2470
+ // would fight it. Fixed numbers are fine - they become the pre-measure estimate.
2471
+ const autoRowHeightOn = $derived(
2472
+ props.autoRowHeight === true && typeof props.rowHeight !== "function",
2473
+ );
2474
+ const autoRowHeightFallback = $derived(
2475
+ typeof props.rowHeight === "number" ? props.rowHeight : 30,
2476
+ );
2477
+
2478
+ /** Report a row's measured height. Called by the view's measuring action.
2479
+ * Sub-pixel churn is ignored so a fractional layout can't loop. */
2480
+ function reportRowHeight(index: number, height: number): void {
2481
+ if (!autoRowHeightOn || height <= 0) return;
2482
+ const prev = measuredRowHeights.get(index);
2483
+ if (prev !== undefined && Math.abs(prev - height) < 1) return;
2484
+ measuredRowHeights.set(index, height);
2485
+ autoRowHeightVersion += 1;
2486
+ }
2487
+
2488
+ /**
2489
+ * Svelte action for a `<tr>` under `autoRowHeight`. Measures the row once on
2490
+ * mount and again whenever its content reflows (a resized column rewraps
2491
+ * text), reporting the natural height back to the virtualizer.
2492
+ *
2493
+ * Uses the shared rAF-deferred observer: the callback bumps state that
2494
+ * re-lays-out the observed element, which is exactly the pattern that trips
2495
+ * "ResizeObserver loop completed with undelivered notifications" when handled
2496
+ * synchronously.
2497
+ */
2498
+ function measureRowHeight(node: HTMLElement, index: number) {
2499
+ let current = index;
2500
+ let stop: (() => void) | null = null;
2501
+ const measure = () => reportRowHeight(current, node.getBoundingClientRect().height);
2502
+ const attach = () => {
2503
+ stop?.();
2504
+ stop = autoRowHeightOn ? observeSizeRaf(node, measure) : null;
2505
+ if (autoRowHeightOn) measure();
2506
+ };
2507
+ attach();
2508
+ return {
2509
+ update(next: number) {
2510
+ if (next === current) return;
2511
+ current = next;
2512
+ // A recycled row now shows different data - re-measure under its new index.
2513
+ measure();
2514
+ },
2515
+ destroy() {
2516
+ stop?.();
2517
+ },
2518
+ };
2519
+ }
2520
+
2521
+ // Row index -> row identity changes when the data does, so stale measurements
2522
+ // would size new rows by the old ones. Drop them when the row set changes.
2523
+ $effect(() => {
2524
+ void allRows.length;
2525
+ void internalData;
2526
+ if (!autoRowHeightOn || measuredRowHeights.size === 0) return;
2527
+ measuredRowHeights.clear();
2528
+ autoRowHeightVersion += 1;
2529
+ });
2530
+
2226
2531
  $effect(() => {
2227
2532
  // When containerHeight is a string (e.g. "100%") the actual pixel height
2228
2533
  // depends on the parent layout - read it from the live scroll container.
@@ -2236,9 +2541,18 @@ export function createSvGridController<
2236
2541
  ? (scrollContainer?.clientHeight ?? 520)
2237
2542
  : (props.containerHeight ?? 520);
2238
2543
  const rh = props.rowHeight;
2544
+ // Auto height: read measured sizes, falling back to the fixed rowHeight as
2545
+ // the estimate for rows that have not rendered yet. Touch the version so a
2546
+ // new measurement re-runs this effect (the Map itself is not reactive).
2547
+ autoRowHeightVersion;
2548
+ const estimateSize = autoRowHeightOn
2549
+ ? (index: number) => measuredRowHeights.get(index) ?? autoRowHeightFallback
2550
+ : typeof rh === "function"
2551
+ ? rh
2552
+ : (rh ?? 30);
2239
2553
  virtualizer.setOptions({
2240
2554
  count: allRows.length,
2241
- estimateSize: typeof rh === "function" ? rh : (rh ?? 30),
2555
+ estimateSize,
2242
2556
  overscan: props.overscan ?? 8,
2243
2557
  viewportHeight,
2244
2558
  });
@@ -2313,6 +2627,20 @@ export function createSvGridController<
2313
2627
  * are resolved on every call because they can change as other cells in
2314
2628
  * the same row change (the whole point of cascading editors). */
2315
2629
  const editorOptionsCache: Record<string, CellEditorOption[]> = {};
2630
+ // Resolved async `editorOptions`, keyed by column id (static source) or
2631
+ // `columnId::rowId` (per-row cascade). Reactive so a fetch landing re-renders
2632
+ // the open editor; `...Pending` both drives the loading state and dedupes the
2633
+ // request across the many synchronous render calls.
2634
+ let asyncEditorOptions = $state<Record<string, CellEditorOption[]>>({});
2635
+ // Deliberately NOT reactive: this is written during render (the resolver runs
2636
+ // inside the template), and mutating $state there is a Svelte 5 error that
2637
+ // takes the whole editor down with it. Only the resolved result needs to be
2638
+ // reactive; the loading flag is derived from "thenable source, no result yet".
2639
+ const asyncEditorOptionsPending = new Set<string>();
2640
+ // Columns whose per-row `editorOptions` has been observed returning a Promise.
2641
+ // Lets the resolver answer from cache without re-invoking the source, so an
2642
+ // async source fires one request per row instead of one per render.
2643
+ const asyncEditorColumns = new Set<string>();
2316
2644
 
2317
2645
 
2318
2646
 
@@ -2950,6 +3278,9 @@ export function createSvGridController<
2950
3278
  get isCellEditableAt() { return isCellEditableAt; },
2951
3279
  get sortDirectionByColumn() { return sortDirectionByColumn; },
2952
3280
  get groupingColumns() { return groupingColumns; },
3281
+ get groupDisplayMode() { return groupDisplayMode; },
3282
+ get groupColumnMode() { return groupColumnMode; },
3283
+ get autoGroupCell() { return autoGroupCell; },
2953
3284
  get paginationState() { return paginationState; },
2954
3285
  get externalPaginationEnabled() { return externalPaginationEnabled; },
2955
3286
  get paginationTotalRows() { return paginationTotalRows; },
@@ -3120,6 +3451,15 @@ export function createSvGridController<
3120
3451
  get rowVirtualizationEnabled() { return rowVirtualizationEnabled; },
3121
3452
  get columnVirtualizationEnabled() { return columnVirtualizationEnabled; },
3122
3453
  get virtualRows() { return virtualRows; },
3454
+ get autoRowHeightOn() { return autoRowHeightOn; },
3455
+ /** Svelte action: measure this row and report its natural height. */
3456
+ get measureRowHeight() { return measureRowHeight; },
3457
+ /** A row's measured height, or undefined before it has been measured.
3458
+ * The non-virtualized body reads this directly (it has no virtual item). */
3459
+ get measuredRowHeightPx() {
3460
+ autoRowHeightVersion;
3461
+ return (index: number): number | undefined => measuredRowHeights.get(index);
3462
+ },
3123
3463
  get virtualRowTotalSize() { return virtualRowTotalSize; },
3124
3464
  get virtualRowStart() { return virtualRowStart; },
3125
3465
  get virtualRowEnd() { return virtualRowEnd; },
@@ -3161,7 +3501,12 @@ export function createSvGridController<
3161
3501
  get getCellDisplayValue() { return getCellDisplayValue; },
3162
3502
  get getColumnAlign() { return getColumnAlign; },
3163
3503
  get editorOptionsCache() { return editorOptionsCache; },
3504
+ get asyncEditorOptions() { return asyncEditorOptions; },
3505
+ set asyncEditorOptions(v) { asyncEditorOptions = v as never; },
3506
+ get asyncEditorOptionsPending() { return asyncEditorOptionsPending; },
3507
+ get asyncEditorColumns() { return asyncEditorColumns; },
3164
3508
  get getColumnEditorOptions() { return getColumnEditorOptions; },
3509
+ get areEditorOptionsLoading() { return areEditorOptionsLoading; },
3165
3510
  get formatListCellValue() { return formatListCellValue; },
3166
3511
  get formatCellValue() { return formatCellValue; },
3167
3512
  get getPinnedCellValue() { return getPinnedCellValue; },
@@ -3297,7 +3642,7 @@ export function createSvGridController<
3297
3642
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
3298
3643
  const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
3299
3644
  const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, setFacetSelection, setFilterTokens, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
3300
- const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
3645
+ const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, areEditorOptionsLoading, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
3301
3646
  const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
3302
3647
  const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
3303
3648
  const { cellPinStyle, isColumnPinned, getCurrentColumnOrder, emitColumnOrder, setColumnOrderInternal, applyColumnDrop, onColumnHeaderDragStart, onColumnHeaderDragOver, onColumnHeaderDragLeave, onColumnHeaderDrop, onColumnHeaderDragEnd, pinColumnLeft, pinColumnRight, unpinColumn, toggleColumnVisibleInPanel, moveColumnInPanel, toggleGroupInPanel, getColumnBaseWidth, getColumnWidth, startColumnResize, onColumnResizeMove, endColumnResize, measureText, autosizeColumn, autosizeAllColumns, resetColumns } = createColumns<TFeatures, TData>(ctx);
package/src/SvGrid.css CHANGED
@@ -902,8 +902,8 @@
902
902
  pattern isn't being applied (it matches nothing until it compiles). */
903
903
  .sv-grid-filter-value.sv-grid-filter-value-invalid,
904
904
  .sv-grid-tp-filter-input.sv-grid-filter-value-invalid {
905
- border-color: var(--sg-danger, #e5484d) !important;
906
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--sg-danger, #e5484d) 45%, transparent);
905
+ border-color: var(--sg-danger, #dc2626) !important;
906
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--sg-danger, #dc2626) 45%, transparent);
907
907
  }
908
908
 
909
909
  /* In / Not-in chip (token) input: chips + a flex-growing text box share one
@@ -2130,6 +2130,104 @@ select.sv-grid-fr-editor {
2130
2130
  background: var(--sg-selection-bg, #eaf2ff);
2131
2131
  }
2132
2132
 
2133
+ /* Group subtotal row (`groupFooters`). Renders through the normal cell path so
2134
+ its totals sit under their own columns; a top rule + weight is what reads as
2135
+ "this closes the group above". Lighter than the banner on purpose - the
2136
+ banner opens a group, the footer just totals it. */
2137
+ .sv-grid-group-footer-row > .sv-grid-cell {
2138
+ background: var(--sg-header-bg, #eef2f8);
2139
+ border-top: 1px solid var(--sg-border, #d7dce5);
2140
+ font-weight: 600;
2141
+ }
2142
+
2143
+ /* Auto row height (`autoRowHeight`). The default cell truncates to one line
2144
+ (nowrap + ellipsis + overflow hidden), which would make a measured row height
2145
+ pointless - every row would measure the same. Let the text wrap so the row
2146
+ has something to grow to, and pad vertically since there is no fixed height
2147
+ centring the single line any more. */
2148
+ .sv-grid-row-auto-height > .sv-grid-cell {
2149
+ white-space: normal;
2150
+ overflow-wrap: anywhere;
2151
+ overflow: visible;
2152
+ text-overflow: clip;
2153
+ padding-top: 6px;
2154
+ padding-bottom: 6px;
2155
+ /* Cells stretch to the row's height rather than centring on one line. */
2156
+ vertical-align: top;
2157
+ }
2158
+
2159
+ /* Grand total (`grandTotalRow`). One step heavier than a group subtotal: it
2160
+ closes the whole dataset, not one group, so it takes a double top rule. */
2161
+ .sv-grid-grand-total-row > .sv-grid-cell {
2162
+ border-top: 3px double var(--sg-border, #d7dce5);
2163
+ font-weight: 700;
2164
+ }
2165
+
2166
+ /* Client tree data (`treeData`). The indent is a sized spacer rather than
2167
+ padding on the cell so conditional-format backgrounds and data bars still
2168
+ span the full cell width. */
2169
+ .sv-grid-tree-indent {
2170
+ display: inline-block;
2171
+ flex: none;
2172
+ }
2173
+ .sv-grid-tree-toggle {
2174
+ display: inline-flex;
2175
+ align-items: center;
2176
+ justify-content: center;
2177
+ width: 14px;
2178
+ height: 14px;
2179
+ flex: none;
2180
+ margin-right: 3px;
2181
+ padding: 0;
2182
+ border: 0;
2183
+ border-radius: 3px;
2184
+ background: transparent;
2185
+ color: var(--sg-muted, #64748b);
2186
+ font-size: 10px;
2187
+ line-height: 1;
2188
+ cursor: pointer;
2189
+ }
2190
+ .sv-grid-tree-toggle:hover {
2191
+ background: var(--sg-header-bg, #eef2f8);
2192
+ color: var(--sg-fg, #0f172a);
2193
+ }
2194
+ /* One chevron for every expander in the library - the grid's tree rows, its
2195
+ group banners and auto-group columns, SvGroupCell and SvTree all draw the
2196
+ same `chevron-right` and rotate it open, rather than swapping between two
2197
+ different glyphs. */
2198
+ .sv-grid-tree-toggle .sv-grid-icon,
2199
+ .sv-grid-group-toggle .sv-grid-icon {
2200
+ width: 12px;
2201
+ height: 12px;
2202
+ transition: transform 0.15s ease;
2203
+ }
2204
+ .sv-grid-tree-toggle[aria-expanded="true"] .sv-grid-icon,
2205
+ .sv-grid-group-toggle[aria-expanded="true"] .sv-grid-icon {
2206
+ transform: rotate(90deg);
2207
+ }
2208
+ .sv-grid-tree-toggle:focus-visible {
2209
+ outline: 2px solid var(--sg-accent, #2563eb);
2210
+ outline-offset: 1px;
2211
+ }
2212
+ /* Auto-group column cell (`groupDisplayMode: singleColumn | multipleColumns`).
2213
+ The group row is an ordinary row here, so only the label needs weight - the
2214
+ full-width banner styling does not apply. */
2215
+ .sv-grid-autogroup-label {
2216
+ font-weight: 600;
2217
+ color: var(--sg-fg, #0f172a);
2218
+ overflow: hidden;
2219
+ text-overflow: ellipsis;
2220
+ white-space: nowrap;
2221
+ }
2222
+
2223
+ /* Keeps a childless row's value on the same x as its expandable siblings. */
2224
+ .sv-grid-tree-spacer {
2225
+ display: inline-block;
2226
+ width: 14px;
2227
+ flex: none;
2228
+ margin-right: 3px;
2229
+ }
2230
+
2133
2231
  .sv-grid-group-cell {
2134
2232
  padding: 0 12px;
2135
2233
  }
@@ -2209,7 +2307,9 @@ select.sv-grid-fr-editor {
2209
2307
  line-height: 1.4;
2210
2308
  resize: vertical;
2211
2309
  outline: none;
2212
- box-shadow: var(--sg-focus-ring, 0 0 0 2px rgba(99, 102, 241, 0.40));
2310
+ /* --sg-focus-ring is a COLOR everywhere else (`outline: 2px solid ...`).
2311
+ Consume it as one here too, rather than as a whole box-shadow value. */
2312
+ box-shadow: 0 0 0 2px var(--sg-focus-ring, rgba(99, 102, 241, 0.40));
2213
2313
  }
2214
2314
 
2215
2315
  .sv-grid-autocomplete {