@svgrid/grid 2.2.31 → 2.2.32

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 (145) hide show
  1. package/README.md +1 -1
  2. package/dist/SvAvatar.svelte +1 -1
  3. package/dist/SvCheckBox.svelte +2 -1
  4. package/dist/SvCircularProgress.svelte +1 -1
  5. package/dist/SvGrid.controller.svelte.d.ts +20 -0
  6. package/dist/SvGrid.controller.svelte.js +358 -18
  7. package/dist/SvGrid.css +103 -3
  8. package/dist/SvGrid.svelte +162 -10
  9. package/dist/SvGrid.types.d.ts +81 -0
  10. package/dist/SvGridDropdown.svelte +38 -6
  11. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  12. package/dist/SvGridSelect.svelte +12 -3
  13. package/dist/SvGridSelect.svelte.d.ts +6 -0
  14. package/dist/SvGroupCell.svelte +9 -2
  15. package/dist/SvMultiSelect.svelte +12 -3
  16. package/dist/SvMultiSelect.svelte.d.ts +6 -0
  17. package/dist/SvPasswordInput.svelte +1 -1
  18. package/dist/SvProgress.svelte +1 -1
  19. package/dist/SvRating.svelte +1 -1
  20. package/dist/SvResult.svelte +1 -1
  21. package/dist/SvRichText.svelte +31 -16
  22. package/dist/SvRichText.svelte.d.ts +9 -0
  23. package/dist/SvSwitchButton.svelte +2 -1
  24. package/dist/SvToaster.svelte +1 -1
  25. package/dist/SvTree.svelte +9 -2
  26. package/dist/SvTree.svelte.d.ts +5 -0
  27. package/dist/SvTreeSelect.svelte +6 -2
  28. package/dist/build-api.js +16 -0
  29. package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-C6ixSGWW.js} +33 -33
  30. package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-Cyk3qpJ7.js} +33 -33
  31. package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-CdqaP61H.js} +2 -1
  32. package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
  33. package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
  34. package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-Dh-2ej8j.js} +92 -87
  35. package/dist/cdn/{src-BLJfdyL0.js → src-BgHjWF0Q.js} +4193 -3837
  36. package/dist/cdn/{src-DYDCiC0D.js → src-CgD-wiuS.js} +5956 -5602
  37. package/dist/cdn/svgrid.js +8 -8
  38. package/dist/cdn/svgrid.svelte-external.js +8 -8
  39. package/dist/cell-render.d.ts +1 -0
  40. package/dist/cell-render.js +115 -1
  41. package/dist/cell-values.js +5 -0
  42. package/dist/core.d.ts +80 -9
  43. package/dist/core.js +89 -0
  44. package/dist/editor-contract.d.ts +12 -5
  45. package/dist/editor-registry.d.ts +18 -7
  46. package/dist/editor-registry.js +10 -1
  47. package/dist/group-display.d.ts +39 -0
  48. package/dist/group-display.js +52 -0
  49. package/dist/index.d.ts +2 -2
  50. package/dist/index.js +2 -2
  51. package/dist/keyboard-handlers.js +16 -0
  52. package/dist/recurrence.js +3 -2
  53. package/dist/svgrid-wrapper.types.d.ts +8 -2
  54. package/dist/themes/index.d.ts +10 -0
  55. package/dist/themes/index.js +26 -0
  56. package/package.json +1 -1
  57. package/src/SvAccordion.test.ts +5 -5
  58. package/src/SvAvatar.svelte +1 -1
  59. package/src/SvButtonGroup.test.ts +6 -6
  60. package/src/SvCheckBox.svelte +2 -1
  61. package/src/SvCircularProgress.svelte +1 -1
  62. package/src/SvFileUpload.test.ts +1 -1
  63. package/src/SvGrid.controller.svelte.ts +367 -17
  64. package/src/SvGrid.css +103 -3
  65. package/src/SvGrid.svelte +162 -10
  66. package/src/SvGrid.types.ts +81 -0
  67. package/src/SvGridDropdown.svelte +38 -6
  68. package/src/SvGridSelect.svelte +12 -3
  69. package/src/SvGroupCell.svelte +9 -2
  70. package/src/SvMultiSelect.svelte +12 -3
  71. package/src/SvPasswordInput.svelte +1 -1
  72. package/src/SvProgress.svelte +1 -1
  73. package/src/SvRating.svelte +1 -1
  74. package/src/SvResult.svelte +1 -1
  75. package/src/SvRichText.svelte +31 -16
  76. package/src/SvSwitchButton.svelte +2 -1
  77. package/src/SvToaster.svelte +1 -1
  78. package/src/SvTree.svelte +9 -2
  79. package/src/SvTreeSelect.svelte +6 -2
  80. package/src/build-api.ts +15 -0
  81. package/src/builtin-editors.test.ts +3 -1
  82. package/src/cell-render.test.ts +27 -3
  83. package/src/cell-render.ts +122 -1
  84. package/src/cell-values.ts +4 -0
  85. package/src/chart-export.test.ts +2 -2
  86. package/src/clipboard.test.ts +0 -2
  87. package/src/core.ts +157 -8
  88. package/src/editing.test.ts +1 -1
  89. package/src/editor-contract.ts +12 -5
  90. package/src/editor-registry.grid.test.ts +48 -0
  91. package/src/editor-registry.test.ts +3 -1
  92. package/src/editor-registry.ts +23 -7
  93. package/src/facet-buckets.test.ts +33 -33
  94. package/src/group-display.test.ts +91 -1
  95. package/src/group-display.ts +83 -0
  96. package/src/index.ts +9 -0
  97. package/src/keyboard-handlers.ts +17 -0
  98. package/src/list-power.test.ts +2 -2
  99. package/src/menus.test.ts +4 -4
  100. package/src/named-views.coverage.test.ts +3 -3
  101. package/src/recurrence.ts +3 -2
  102. package/src/row-resize.test.ts +40 -40
  103. package/src/scheduler-model.ts +1 -1
  104. package/src/server-data-source.coverage.test.ts +1 -1
  105. package/src/server-group-model.test.ts +6 -6
  106. package/src/spreadsheet.test.ts +0 -1
  107. package/src/svgrid-wrapper.types.ts +11 -9
  108. package/src/svgrid.async-editor-options.test.ts +271 -0
  109. package/src/svgrid.auto-row-height.test.ts +204 -0
  110. package/src/svgrid.charting.test.ts +4 -4
  111. package/src/svgrid.comments-autocomplete.test.ts +8 -2
  112. package/src/svgrid.context-menu.test.ts +25 -9
  113. package/src/svgrid.filter-depth.test.ts +163 -0
  114. package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
  115. package/src/svgrid.filter-menu-scroll.test.ts +10 -2
  116. package/src/svgrid.grand-total.test.ts +188 -0
  117. package/src/svgrid.group-display-mode.test.ts +171 -0
  118. package/src/svgrid.group-footers.test.ts +121 -0
  119. package/src/svgrid.group-pagination.test.ts +153 -0
  120. package/src/svgrid.tree-data.test.ts +186 -0
  121. package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
  122. package/src/themes/index.ts +42 -0
  123. package/src/tree-edit.test.ts +4 -4
  124. package/src/tree-row-model.test.ts +169 -0
  125. package/src/ui-localization.test.ts +113 -0
  126. package/themes/ag-alpine.css +22 -0
  127. package/themes/antd.css +22 -0
  128. package/themes/atlassian.css +22 -0
  129. package/themes/bootstrap.css +22 -0
  130. package/themes/carbon.css +22 -0
  131. package/themes/catppuccin.css +22 -0
  132. package/themes/dracula.css +22 -0
  133. package/themes/ember.css +22 -0
  134. package/themes/excel.css +22 -0
  135. package/themes/fluent.css +22 -0
  136. package/themes/github.css +22 -0
  137. package/themes/linear.css +22 -0
  138. package/themes/material.css +22 -0
  139. package/themes/nord.css +22 -0
  140. package/themes/notion.css +22 -0
  141. package/themes/salesforce.css +22 -0
  142. package/themes/sap.css +22 -0
  143. package/themes/shadcn.css +22 -0
  144. package/themes/tailwind.css +22 -0
  145. package/themes/vercel.css +22 -0
@@ -28,10 +28,10 @@ describe('SvButtonGroup', () => {
28
28
  expect(group.getAttribute('role')).toBe('radiogroup')
29
29
  const btns = [...target.querySelectorAll<HTMLButtonElement>('.sv-bg__btn')]
30
30
  expect(btns.map((b) => b.getAttribute('role'))).toEqual(['radio', 'radio', 'radio'])
31
- expect(btns[1].getAttribute('aria-checked')).toBe('true')
31
+ expect(btns[1]!.getAttribute('aria-checked')).toBe('true')
32
32
  // Roving tabindex: only the checked/active button is tabbable.
33
- expect(btns[1].tabIndex).toBe(0)
34
- expect(btns[0].tabIndex).toBe(-1)
33
+ expect(btns[1]!.tabIndex).toBe(0)
34
+ expect(btns[0]!.tabIndex).toBe(-1)
35
35
  } finally { destroy() }
36
36
  })
37
37
 
@@ -40,7 +40,7 @@ describe('SvButtonGroup', () => {
40
40
  const { target, destroy } = mountBg({ items, value: 'day', mode: 'single', onChange: (v: unknown) => (got = v) })
41
41
  try {
42
42
  const btns = target.querySelectorAll<HTMLButtonElement>('.sv-bg__btn')
43
- btns[2].click()
43
+ btns[2]!.click()
44
44
  flushSync()
45
45
  expect(got).toBe('month')
46
46
  } finally { destroy() }
@@ -53,8 +53,8 @@ describe('SvButtonGroup', () => {
53
53
  const group = target.querySelector<HTMLElement>('.sv-bg')!
54
54
  expect(group.getAttribute('role')).toBe('group')
55
55
  const btns = target.querySelectorAll<HTMLButtonElement>('.sv-bg__btn')
56
- expect(btns[0].getAttribute('aria-pressed')).toBe('true')
57
- btns[1].click()
56
+ expect(btns[0]!.getAttribute('aria-pressed')).toBe('true')
57
+ btns[1]!.click()
58
58
  flushSync()
59
59
  expect(got).toEqual(['day', 'week'])
60
60
  } finally { destroy() }
@@ -56,8 +56,9 @@
56
56
 
57
57
  <SvField id={uid} {hint} {error} {required} {dir}>
58
58
  <label class="sv-check sv-check--{size}" class:is-disabled={disabled} class:is-readonly={readonly} class:is-invalid={invalid}>
59
+ <!-- role="checkbox" arrives via boxProps(), which the a11y pass cannot see through -->
60
+ <!-- svelte-ignore a11y_role_supports_aria_props_implicit -->
59
61
  <button
60
- role="checkbox"
61
62
  class="sv-check__box"
62
63
  class:is-checked={checked && !indeterminate}
63
64
  class:is-indeterminate={indeterminate}
@@ -89,7 +89,7 @@
89
89
  position: relative; display: inline-flex; flex: none;
90
90
  }
91
91
  .sv-circ--success { --_c: var(--sg-success, #16a34a); }
92
- .sv-circ--warning { --_c: var(--sg-warning, #f59e0b); }
92
+ .sv-circ--warning { --_c: var(--sg-warning, #d97706); }
93
93
  .sv-circ--danger { --_c: var(--sg-danger, #dc2626); }
94
94
  .sv-circ__svg { display: block; transform: rotate(-90deg); }
95
95
  .sv-circ__track { stroke: var(--sg-border, #e2e8f0); }
@@ -53,7 +53,7 @@ describe('SvFileUpload', () => {
53
53
  target.querySelector<HTMLButtonElement>('.sv-file__remove')!.click()
54
54
  flushSync()
55
55
  expect(got).toHaveLength(1)
56
- expect(got![0].name).toBe('b.png')
56
+ expect(got![0]!.name).toBe('b.png')
57
57
  } finally { destroy() }
58
58
  })
59
59
  })
@@ -1,11 +1,13 @@
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,
@@ -88,6 +90,7 @@ import {
88
90
  hiddenLeavesForCollapse,
89
91
  } from "./column-groups";
90
92
  import { resolveColumnId } from "./column-id";
93
+ import { buildAutoGroupColumns, insertGroupFooters, paginateGroupedRows } from "./group-display";
91
94
  import {
92
95
  createGridApi,
93
96
  } from "./build-api";
@@ -667,6 +670,10 @@ export function createSvGridController<
667
670
  const externalPaginationEnabled = $derived(props.externalPagination === true);
668
671
  const passthroughSortedRowModel = ({ rows }: { rows: Array<Row<TData>> }) =>
669
672
  rows;
673
+ // Client tree data. Structural like external sort, so captured once at mount -
674
+ // the row-model pipeline is read before any later prop change could apply.
675
+ // svelte-ignore state_referenced_locally
676
+ const treeDataConfig = props.treeData;
670
677
 
671
678
 
672
679
  const grid = createSvGrid({
@@ -687,7 +694,14 @@ export function createSvGridController<
687
694
  sortedRowModel: externalSortEnabled
688
695
  ? passthroughSortedRowModel
689
696
  : createSortedRowModel<TData>(sortFns),
690
- groupedRowModel: createGroupedRowModel<TData>(),
697
+ // Tree nesting replaces grouping when `treeData` is set - a row cannot
698
+ // be both a hierarchy node and bucketed under a group banner.
699
+ groupedRowModel: treeDataConfig
700
+ ? createTreeRowModel<TData>({
701
+ parentField: treeDataConfig.parentField,
702
+ idField: treeDataConfig.idField,
703
+ })
704
+ : createGroupedRowModel<TData>(),
691
705
  expandedRowModel: createExpandedRowModel<TData>(),
692
706
  };
693
707
  },
@@ -771,6 +785,103 @@ export function createSvGridController<
771
785
  userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
772
786
  });
773
787
 
788
+ // Declared here rather than beside the other pagination/state derivations:
789
+ // the auto-group column pipeline below reads it, and a `const` used above its
790
+ // declaration is a type error even though `$derived` is lazy enough at runtime.
791
+ const groupingColumns = $derived.by(() => {
792
+ gridStateVersion;
793
+ return grid.getState().grouping ?? [];
794
+ });
795
+
796
+ /**
797
+ * Row-grouping display mode. `groupRows` (the default) renders a full-width
798
+ * banner per group - the historical behaviour. `singleColumn` folds every
799
+ * grouping level into one synthetic column; `multipleColumns` gives each
800
+ * grouped field its own. Both hide the grouped SOURCE columns, since their
801
+ * values now live in the auto column.
802
+ */
803
+ const groupDisplayMode = $derived(props.groupDisplayMode ?? "groupRows");
804
+ const autoGroupSpec = $derived(
805
+ buildAutoGroupColumns(groupingColumns, groupDisplayMode),
806
+ );
807
+ /** True when group state is shown in columns rather than banner rows. */
808
+ const groupColumnMode = $derived(autoGroupSpec.autoColumns.length > 0);
809
+
810
+ /**
811
+ * The synthetic auto-group columns as real `Column` objects, so the rest of
812
+ * the pipeline (widths, pinning, virtualization, headers) treats them like
813
+ * any other column. They carry no `field` - their cell content is resolved
814
+ * from the row's group state at render time.
815
+ */
816
+ const autoGroupColumns = $derived.by(() => {
817
+ if (!groupColumnMode) return [] as Column<TData>[];
818
+ const sourceById = new Map(grid.getAllColumns().map((c) => [c.id, c]));
819
+ return autoGroupSpec.autoColumns.map((spec) => {
820
+ const source = spec.field ? sourceById.get(spec.field) : undefined;
821
+ const sourceHeader = source?.columnDef.header;
822
+ const header =
823
+ spec.field == null
824
+ ? (props.autoGroupColumnHeader ?? "Group")
825
+ : typeof sourceHeader === "string"
826
+ ? sourceHeader
827
+ : spec.field;
828
+ const column: Column<TData> = {
829
+ id: spec.id,
830
+ depth: 0,
831
+ columnDef: {
832
+ header,
833
+ width: props.autoGroupColumnWidth ?? 220,
834
+ sortable: false,
835
+ filterable: false,
836
+ } as never,
837
+ getCanSort: () => false,
838
+ getCanFilter: () => false,
839
+ getIsSorted: () => false,
840
+ getToggleSortingHandler: () => () => {},
841
+ };
842
+ return column;
843
+ });
844
+ });
845
+
846
+ /**
847
+ * What an auto-group column shows for a given row.
848
+ *
849
+ * `singleColumn` puts every level in one column, so a group row renders its
850
+ * own level (indented by depth) and leaf rows stay blank - the ancestry is
851
+ * already visible in the banner rows above.
852
+ *
853
+ * `multipleColumns` gives each grouped field a column, so a group row only
854
+ * writes into the column matching ITS level and leaves the others blank.
855
+ *
856
+ * Returns null when the cell should render nothing.
857
+ */
858
+ function autoGroupCell(
859
+ row: Row<TData>,
860
+ columnId: string,
861
+ ): { label: string; count: number; depth: number } | null {
862
+ if (!isGroupRow(row)) return null;
863
+ const depth = row.depth ?? 0;
864
+ if (columnId !== "__autoGroup") {
865
+ // multipleColumns: only the column for this row's own level renders.
866
+ const field = groupingColumns[depth];
867
+ if (!field || `__group_${field}` !== columnId) return null;
868
+ }
869
+ const groupingColumnId = groupingColumns[depth] ?? "";
870
+ const sourceColumn = grid
871
+ .getAllColumns()
872
+ .find((c) => c.id === groupingColumnId);
873
+ const raw = row.getCellValueByColumnId(groupingColumnId);
874
+ const label = sourceColumn
875
+ ? formatCellValue(sourceColumn, raw, row)
876
+ : String(raw ?? "");
877
+ return {
878
+ label,
879
+ count: row.leafCount ?? row.subRows?.length ?? 0,
880
+ // singleColumn indents by level; multipleColumns already separates them.
881
+ depth: columnId === "__autoGroup" ? depth : 0,
882
+ };
883
+ }
884
+
774
885
  const allColumns = $derived.by(() => {
775
886
  let raw = grid
776
887
  .getAllColumns()
@@ -778,8 +889,13 @@ export function createSvGridController<
778
889
  (column) =>
779
890
  !hiddenColumns[column.id] &&
780
891
  !hiddenByGroupCollapse[column.id] &&
781
- !isHiddenByResponsive(column),
892
+ !isHiddenByResponsive(column) &&
893
+ // In a column display mode the grouped columns are folded into the
894
+ // auto column(s), so showing them too would just duplicate the value.
895
+ !autoGroupSpec.hiddenSourceIds.has(column.id),
782
896
  );
897
+ // Auto-group columns lead, like a row header.
898
+ if (autoGroupColumns.length) raw = [...autoGroupColumns, ...raw];
783
899
  // Apply user reorder (if any). Unknown ids in userColumnOrder are
784
900
  // skipped; columns not in userColumnOrder keep their original
785
901
  // relative order after the user-ordered ones.
@@ -821,7 +937,21 @@ export function createSvGridController<
821
937
  const headers: (typeof base)[number]["headers"] = [];
822
938
  for (const column of allColumns) {
823
939
  const header = byId.get(column.id);
824
- if (header) headers.push(header);
940
+ if (header) {
941
+ headers.push(header);
942
+ continue;
943
+ }
944
+ // Auto-group columns have no engine header. Synthesize one rather than
945
+ // skipping: the header row indexes `headers` BY POSITION against the
946
+ // rendered columns, so a gap would shift every later header by one.
947
+ const synthetic = {
948
+ id: column.id,
949
+ isPlaceholder: false,
950
+ colSpan: 1,
951
+ column,
952
+ getContext: () => ({ header: synthetic, column, table: grid }),
953
+ } as (typeof base)[number]["headers"][number];
954
+ headers.push(synthetic);
825
955
  }
826
956
  return [{ id: base[0]!.id, headers }];
827
957
  });
@@ -1098,10 +1228,6 @@ export function createSvGridController<
1098
1228
  return directions;
1099
1229
  });
1100
1230
 
1101
- const groupingColumns = $derived.by(() => {
1102
- gridStateVersion;
1103
- return grid.getState().grouping ?? [];
1104
- });
1105
1231
 
1106
1232
  const paginationState = $derived.by(() => {
1107
1233
  gridStateVersion;
@@ -1221,22 +1347,137 @@ export function createSvGridController<
1221
1347
  * the full dataset rather than the current page (see the comment above
1222
1348
  * `_rowModels`).
1223
1349
  */
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;
1350
+ /**
1351
+ * Grouped pagination. `allRowsBeforePagination` interleaves group banners with
1352
+ * data rows, so a plain slice spends the page budget on banners - ten groups of
1353
+ * two at `pageSize: 10` used to show about three data rows (#73). When grouping
1354
+ * is active we page by DATA rows and reprint each page's ancestor banners.
1355
+ * Null when grouping is off, so the flat path stays a cheap slice.
1356
+ */
1357
+ const groupedPage = $derived.by(() => {
1358
+ if (!paginationEnabled || externalPaginationEnabled) return null;
1359
+ if (!groupingColumns.length) return null;
1228
1360
  const { pageIndex, pageSize } = paginationState;
1229
- const start = pageIndex * pageSize;
1230
- return rows.slice(start, start + pageSize);
1361
+ return paginateGroupedRows(allRowsBeforePagination, {
1362
+ getDepth: (row: any) => row.depth ?? 0,
1363
+ isGroup: (row: any) => isGroupRow(row),
1364
+ // A collapsed group is the visible unit and takes a page slot; an expanded
1365
+ // one is a banner reprinted above its children.
1366
+ isExpanded: (row: any) => row.getIsExpanded?.() === true,
1367
+ pageIndex,
1368
+ pageSize,
1369
+ });
1231
1370
  });
1232
1371
 
1372
+ /**
1373
+ * The grand-total row: one synthetic row carrying each column's `aggregate`
1374
+ * over the whole filtered set. Shaped like a group footer so it renders
1375
+ * through the normal cell path - totals land under the columns they belong
1376
+ * to, and `getCanExpand`/`getIsExpanded` are off so nothing treats it as a
1377
+ * banner. Returns null when no column aggregates (nothing to total).
1378
+ *
1379
+ * Aggregates the LEAF rows: `allRowsBeforePagination` interleaves group
1380
+ * banners once grouping is on, and those carry per-group subtotals that
1381
+ * would otherwise be summed a second time.
1382
+ */
1383
+ const buildGrandTotalRow = () => {
1384
+ const leaves = allRowsBeforePagination.filter((row: any) => !isGroupRow(row));
1385
+ const columns = grid.getAllColumns();
1386
+ const original: Record<string, unknown> = {};
1387
+ let hasAggregate = false;
1388
+ for (const column of columns) {
1389
+ const field = column.columnDef.field;
1390
+ const agg = column.columnDef.aggregate;
1391
+ if (!field || !agg) continue;
1392
+ hasAggregate = true;
1393
+ original[field] = applyGroupAggregate(agg, column.id, leaves as any);
1394
+ }
1395
+ if (!hasAggregate) return null;
1396
+ return {
1397
+ id: "__grand_total__",
1398
+ index: -1,
1399
+ original,
1400
+ depth: 0,
1401
+ subRows: [],
1402
+ leafCount: leaves.length,
1403
+ __groupFooter: true,
1404
+ __grandTotal: true,
1405
+ getCanExpand: () => false,
1406
+ getIsExpanded: () => false,
1407
+ toggleExpanded: () => {},
1408
+ getIsSelected: () => false,
1409
+ toggleSelected: () => {},
1410
+ getAllCells: () => [],
1411
+ getCellValueByColumnId: (columnId: string) => {
1412
+ const col = columns.find((c) => c.id === columnId);
1413
+ const field = col?.columnDef.field;
1414
+ return field && field in original ? original[field] : undefined;
1415
+ },
1416
+ } as any;
1417
+ };
1418
+
1419
+ /**
1420
+ * Visible rows for the current page. Applied last so filters operate on
1421
+ * the full dataset rather than the current page (see the comment above
1422
+ * `_rowModels`).
1423
+ */
1424
+ const allRows = $derived.by(() => {
1425
+ const paged = (() => {
1426
+ const rows = allRowsBeforePagination;
1427
+ // External pagination: `data` already IS the current page - never slice.
1428
+ if (!paginationEnabled || externalPaginationEnabled) return rows;
1429
+ if (groupedPage) return groupedPage.rows;
1430
+ const { pageIndex, pageSize } = paginationState;
1431
+ const start = pageIndex * pageSize;
1432
+ return rows.slice(start, start + pageSize);
1433
+ })();
1434
+ const wantGroupFooters = Boolean(props.groupFooters) && groupingColumns.length > 0;
1435
+ // The grand total belongs at the END of the dataset, so it is appended only
1436
+ // on the last page - otherwise every page would end in a "total" that isn't
1437
+ // one. With pagination off (or external, where the consumer owns paging and
1438
+ // `data` is already the page) there is only one page to be last.
1439
+ const isLastPage = (() => {
1440
+ if (!paginationEnabled || externalPaginationEnabled) return true;
1441
+ const { pageIndex, pageSize } = paginationState;
1442
+ const total = groupedPage ? groupedPage.dataRowCount : allRowsBeforePagination.length;
1443
+ return pageIndex >= Math.ceil(total / pageSize) - 1;
1444
+ })();
1445
+ const wantGrandTotal = Boolean(props.grandTotalRow) && isLastPage;
1446
+ if (!wantGroupFooters && !wantGrandTotal) return paged;
1447
+ // Footers are inserted AFTER paging so they never eat page budget. A footer
1448
+ // is a clone of its group row, which already carries the group's aggregate
1449
+ // values - so it renders through the normal cell path and the totals land
1450
+ // under the columns they belong to, with `getCanExpand` off so nothing
1451
+ // treats it as a banner.
1452
+ return insertGroupFooters(paged, {
1453
+ getDepth: (row: any) => row.depth ?? 0,
1454
+ isGroup: (row: any) => isGroupRow(row),
1455
+ includeGroupFooter: wantGroupFooters,
1456
+ includeGrandTotalFooter: wantGrandTotal,
1457
+ makeGrandTotal: buildGrandTotalRow,
1458
+ makeFooter: (group: any) => ({
1459
+ ...group,
1460
+ id: `${group.id}__footer`,
1461
+ subRows: [],
1462
+ __groupFooter: true,
1463
+ getCanExpand: () => false,
1464
+ getIsExpanded: () => false,
1465
+ }),
1466
+ }) as typeof paged;
1467
+ });
1468
+
1469
+ /** Rows the pager divides into pages: data rows only once grouping is on. */
1470
+ const paginatedRowTotal = $derived(
1471
+ groupedPage ? groupedPage.dataRowCount : allRowsBeforePagination.length,
1472
+ );
1473
+
1233
1474
  // When a filter reduces the dataset, the stored pageIndex can point beyond
1234
1475
  // the last valid page. Reset to page 0 so the grid never shows a blank body.
1235
1476
  // Skipped for external pagination, where the consumer owns pageIndex.
1236
1477
  $effect(() => {
1237
1478
  if (!paginationEnabled || externalPaginationEnabled) return;
1238
1479
  const { pageIndex, pageSize } = paginationState;
1239
- const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1480
+ const pageCount = Math.ceil(paginatedRowTotal / pageSize);
1240
1481
  if (pageCount > 0 && pageIndex >= pageCount) {
1241
1482
  grid.setPagination({ pageIndex: 0, pageSize });
1242
1483
  }
@@ -1245,7 +1486,7 @@ export function createSvGridController<
1245
1486
  // Footer-facing pagination values. In external mode they come from the
1246
1487
  // consumer-controlled props; otherwise from the local row model + state.
1247
1488
  const paginationTotalRows = $derived(
1248
- externalPaginationEnabled ? (props.rowCount ?? 0) : allRowsBeforePagination.length,
1489
+ externalPaginationEnabled ? (props.rowCount ?? 0) : paginatedRowTotal,
1249
1490
  );
1250
1491
  const paginationPageIndex = $derived(
1251
1492
  externalPaginationEnabled ? (props.pageIndex ?? 0) : paginationState.pageIndex,
@@ -2223,6 +2464,75 @@ export function createSvGridController<
2223
2464
  el.removeEventListener("scroll-change", onHorizontal as EventListener);
2224
2465
  });
2225
2466
 
2467
+ // --- Auto row height -------------------------------------------------
2468
+ // Measured natural heights, keyed by row index. A plain Map (not $state):
2469
+ // it is written from a ResizeObserver during layout, and making it reactive
2470
+ // would re-run the render that is doing the measuring. `autoRowHeightVersion`
2471
+ // is the reactive signal instead, bumped only when a height actually changes.
2472
+ const measuredRowHeights = new Map<number, number>();
2473
+ let autoRowHeightVersion = $state(0);
2474
+ // A per-row `rowHeight` function already supplies heights, so auto-measuring
2475
+ // would fight it. Fixed numbers are fine - they become the pre-measure estimate.
2476
+ const autoRowHeightOn = $derived(
2477
+ props.autoRowHeight === true && typeof props.rowHeight !== "function",
2478
+ );
2479
+ const autoRowHeightFallback = $derived(
2480
+ typeof props.rowHeight === "number" ? props.rowHeight : 30,
2481
+ );
2482
+
2483
+ /** Report a row's measured height. Called by the view's measuring action.
2484
+ * Sub-pixel churn is ignored so a fractional layout can't loop. */
2485
+ function reportRowHeight(index: number, height: number): void {
2486
+ if (!autoRowHeightOn || height <= 0) return;
2487
+ const prev = measuredRowHeights.get(index);
2488
+ if (prev !== undefined && Math.abs(prev - height) < 1) return;
2489
+ measuredRowHeights.set(index, height);
2490
+ autoRowHeightVersion += 1;
2491
+ }
2492
+
2493
+ /**
2494
+ * Svelte action for a `<tr>` under `autoRowHeight`. Measures the row once on
2495
+ * mount and again whenever its content reflows (a resized column rewraps
2496
+ * text), reporting the natural height back to the virtualizer.
2497
+ *
2498
+ * Uses the shared rAF-deferred observer: the callback bumps state that
2499
+ * re-lays-out the observed element, which is exactly the pattern that trips
2500
+ * "ResizeObserver loop completed with undelivered notifications" when handled
2501
+ * synchronously.
2502
+ */
2503
+ function measureRowHeight(node: HTMLElement, index: number) {
2504
+ let current = index;
2505
+ let stop: (() => void) | null = null;
2506
+ const measure = () => reportRowHeight(current, node.getBoundingClientRect().height);
2507
+ const attach = () => {
2508
+ stop?.();
2509
+ stop = autoRowHeightOn ? observeSizeRaf(node, measure) : null;
2510
+ if (autoRowHeightOn) measure();
2511
+ };
2512
+ attach();
2513
+ return {
2514
+ update(next: number) {
2515
+ if (next === current) return;
2516
+ current = next;
2517
+ // A recycled row now shows different data - re-measure under its new index.
2518
+ measure();
2519
+ },
2520
+ destroy() {
2521
+ stop?.();
2522
+ },
2523
+ };
2524
+ }
2525
+
2526
+ // Row index -> row identity changes when the data does, so stale measurements
2527
+ // would size new rows by the old ones. Drop them when the row set changes.
2528
+ $effect(() => {
2529
+ void allRows.length;
2530
+ void internalData;
2531
+ if (!autoRowHeightOn || measuredRowHeights.size === 0) return;
2532
+ measuredRowHeights.clear();
2533
+ autoRowHeightVersion += 1;
2534
+ });
2535
+
2226
2536
  $effect(() => {
2227
2537
  // When containerHeight is a string (e.g. "100%") the actual pixel height
2228
2538
  // depends on the parent layout - read it from the live scroll container.
@@ -2236,9 +2546,18 @@ export function createSvGridController<
2236
2546
  ? (scrollContainer?.clientHeight ?? 520)
2237
2547
  : (props.containerHeight ?? 520);
2238
2548
  const rh = props.rowHeight;
2549
+ // Auto height: read measured sizes, falling back to the fixed rowHeight as
2550
+ // the estimate for rows that have not rendered yet. Touch the version so a
2551
+ // new measurement re-runs this effect (the Map itself is not reactive).
2552
+ autoRowHeightVersion;
2553
+ const estimateSize = autoRowHeightOn
2554
+ ? (index: number) => measuredRowHeights.get(index) ?? autoRowHeightFallback
2555
+ : typeof rh === "function"
2556
+ ? rh
2557
+ : (rh ?? 30);
2239
2558
  virtualizer.setOptions({
2240
2559
  count: allRows.length,
2241
- estimateSize: typeof rh === "function" ? rh : (rh ?? 30),
2560
+ estimateSize,
2242
2561
  overscan: props.overscan ?? 8,
2243
2562
  viewportHeight,
2244
2563
  });
@@ -2313,6 +2632,20 @@ export function createSvGridController<
2313
2632
  * are resolved on every call because they can change as other cells in
2314
2633
  * the same row change (the whole point of cascading editors). */
2315
2634
  const editorOptionsCache: Record<string, CellEditorOption[]> = {};
2635
+ // Resolved async `editorOptions`, keyed by column id (static source) or
2636
+ // `columnId::rowId` (per-row cascade). Reactive so a fetch landing re-renders
2637
+ // the open editor; `...Pending` both drives the loading state and dedupes the
2638
+ // request across the many synchronous render calls.
2639
+ let asyncEditorOptions = $state<Record<string, CellEditorOption[]>>({});
2640
+ // Deliberately NOT reactive: this is written during render (the resolver runs
2641
+ // inside the template), and mutating $state there is a Svelte 5 error that
2642
+ // takes the whole editor down with it. Only the resolved result needs to be
2643
+ // reactive; the loading flag is derived from "thenable source, no result yet".
2644
+ const asyncEditorOptionsPending = new Set<string>();
2645
+ // Columns whose per-row `editorOptions` has been observed returning a Promise.
2646
+ // Lets the resolver answer from cache without re-invoking the source, so an
2647
+ // async source fires one request per row instead of one per render.
2648
+ const asyncEditorColumns = new Set<string>();
2316
2649
 
2317
2650
 
2318
2651
 
@@ -2950,6 +3283,9 @@ export function createSvGridController<
2950
3283
  get isCellEditableAt() { return isCellEditableAt; },
2951
3284
  get sortDirectionByColumn() { return sortDirectionByColumn; },
2952
3285
  get groupingColumns() { return groupingColumns; },
3286
+ get groupDisplayMode() { return groupDisplayMode; },
3287
+ get groupColumnMode() { return groupColumnMode; },
3288
+ get autoGroupCell() { return autoGroupCell; },
2953
3289
  get paginationState() { return paginationState; },
2954
3290
  get externalPaginationEnabled() { return externalPaginationEnabled; },
2955
3291
  get paginationTotalRows() { return paginationTotalRows; },
@@ -3120,6 +3456,15 @@ export function createSvGridController<
3120
3456
  get rowVirtualizationEnabled() { return rowVirtualizationEnabled; },
3121
3457
  get columnVirtualizationEnabled() { return columnVirtualizationEnabled; },
3122
3458
  get virtualRows() { return virtualRows; },
3459
+ get autoRowHeightOn() { return autoRowHeightOn; },
3460
+ /** Svelte action: measure this row and report its natural height. */
3461
+ get measureRowHeight() { return measureRowHeight; },
3462
+ /** A row's measured height, or undefined before it has been measured.
3463
+ * The non-virtualized body reads this directly (it has no virtual item). */
3464
+ get measuredRowHeightPx() {
3465
+ autoRowHeightVersion;
3466
+ return (index: number): number | undefined => measuredRowHeights.get(index);
3467
+ },
3123
3468
  get virtualRowTotalSize() { return virtualRowTotalSize; },
3124
3469
  get virtualRowStart() { return virtualRowStart; },
3125
3470
  get virtualRowEnd() { return virtualRowEnd; },
@@ -3161,7 +3506,12 @@ export function createSvGridController<
3161
3506
  get getCellDisplayValue() { return getCellDisplayValue; },
3162
3507
  get getColumnAlign() { return getColumnAlign; },
3163
3508
  get editorOptionsCache() { return editorOptionsCache; },
3509
+ get asyncEditorOptions() { return asyncEditorOptions; },
3510
+ set asyncEditorOptions(v) { asyncEditorOptions = v as never; },
3511
+ get asyncEditorOptionsPending() { return asyncEditorOptionsPending; },
3512
+ get asyncEditorColumns() { return asyncEditorColumns; },
3164
3513
  get getColumnEditorOptions() { return getColumnEditorOptions; },
3514
+ get areEditorOptionsLoading() { return areEditorOptionsLoading; },
3165
3515
  get formatListCellValue() { return formatListCellValue; },
3166
3516
  get formatCellValue() { return formatCellValue; },
3167
3517
  get getPinnedCellValue() { return getPinnedCellValue; },
@@ -3297,7 +3647,7 @@ export function createSvGridController<
3297
3647
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
3298
3648
  const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
3299
3649
  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);
3650
+ const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, areEditorOptionsLoading, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
3301
3651
  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
3652
  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
3653
  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);