@svgrid/grid 2.2.30 → 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
package/README.md CHANGED
@@ -88,7 +88,7 @@ all wire up the moment you turn on the matching prop.
88
88
 
89
89
  - **Virtual scrolling** - row + column windowing; 100k x 100 stays smooth, with a 1M-row demo.
90
90
  - **Filtering** - Excel-style filter menu, inline filter row, set / value-list filter, locale-aware matching, between operators.
91
- - **Editing** - 14 built-in `editorType`s (text, number, date, datetime, time, select, rich-select with typeahead, textarea, color, checkbox, list, chips, rating, password) plus a `cellEditor` snippet slot for anything custom.
91
+ - **Editing** - 15 built-in `editorType`s (text, number, date, datetime, time, select, rich-select with typeahead, autocomplete, textarea, color, checkbox, list, chips, rating, password), plus `date-native` / `datetime-native` / `time-native` to opt out of the rich pickers, and a `cellEditor` snippet slot for anything custom.
92
92
  - **Selection** - cell-range click+drag and Shift+arrows, copy/paste as TSV, Excel-style fill handle, row selection.
93
93
  - **Views** - row grouping with aggregation, tree data, master/detail, full-width detail rows, spreadsheet mode with formulas.
94
94
  - **Layout** - row + column pinning, sticky header + first column, header drag-to-reorder, autosize, keyboard-accessible column sizing, zebra rows, responsive mode.
@@ -71,5 +71,5 @@
71
71
  .sv-avatar__status.is-online { background: var(--sg-success, #16a34a); }
72
72
  .sv-avatar__status.is-offline { background: var(--sg-muted, #94a3b8); }
73
73
  .sv-avatar__status.is-busy { background: var(--sg-danger, #dc2626); }
74
- .sv-avatar__status.is-away { background: var(--sg-warning, #f59e0b); }
74
+ .sv-avatar__status.is-away { background: var(--sg-warning, #d97706); }
75
75
  </style>
@@ -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); }
@@ -260,6 +260,13 @@ export declare function createSvGridController<TFeatures extends TableFeatures =
260
260
  readonly isCellEditableAt: (rowIndex: number, colIndex: number) => boolean;
261
261
  readonly sortDirectionByColumn: Record<string, false | "asc" | "desc">;
262
262
  readonly groupingColumns: any;
263
+ readonly groupDisplayMode: import("./group-display").GroupDisplayType;
264
+ readonly groupColumnMode: boolean;
265
+ readonly autoGroupCell: (row: Row<TData>, columnId: string) => {
266
+ label: string;
267
+ count: number;
268
+ depth: number;
269
+ } | null;
263
270
  readonly paginationState: any;
264
271
  readonly externalPaginationEnabled: boolean;
265
272
  readonly paginationTotalRows: number;
@@ -413,6 +420,15 @@ export declare function createSvGridController<TFeatures extends TableFeatures =
413
420
  readonly rowVirtualizationEnabled: boolean;
414
421
  readonly columnVirtualizationEnabled: boolean;
415
422
  readonly virtualRows: import("./index").VirtualItem[];
423
+ readonly autoRowHeightOn: boolean;
424
+ /** Svelte action: measure this row and report its natural height. */
425
+ readonly measureRowHeight: (node: HTMLElement, index: number) => {
426
+ update(next: number): void;
427
+ destroy(): void;
428
+ };
429
+ /** A row's measured height, or undefined before it has been measured.
430
+ * The non-virtualized body reads this directly (it has no virtual item). */
431
+ readonly measuredRowHeightPx: (index: number) => number | undefined;
416
432
  readonly virtualRowTotalSize: number;
417
433
  readonly virtualRowStart: number;
418
434
  readonly virtualRowEnd: number;
@@ -469,7 +485,11 @@ export declare function createSvGridController<TFeatures extends TableFeatures =
469
485
  readonly getCellDisplayValue: (rowId: string, columnId: string, baseValue: unknown) => any;
470
486
  readonly getColumnAlign: typeof getColumnAlign;
471
487
  readonly editorOptionsCache: Record<string, CellEditorOption[]>;
488
+ asyncEditorOptions: Record<string, CellEditorOption[]>;
489
+ readonly asyncEditorOptionsPending: Set<string>;
490
+ readonly asyncEditorColumns: Set<string>;
472
491
  readonly getColumnEditorOptions: (column: Column<TData>, row?: Row<TData> | null | undefined) => CellEditorOption[];
492
+ readonly areEditorOptionsLoading: (column: Column<TData>, row?: Row<TData> | null | undefined) => boolean;
473
493
  readonly formatListCellValue: (column: Column<TData>, value: unknown, row?: Row<TData> | null | undefined) => string;
474
494
  readonly formatCellValue: (column: Column<TData>, value: unknown, row: Row<TData>) => string;
475
495
  readonly getPinnedCellValue: typeof getPinnedCellValue;
@@ -1,4 +1,4 @@
1
- import { applyExcelFilter, normalizeForFilter, createColumnVirtualizer, createCoreRowModel, createExpandedRowModel, createFilteredRowModel, createGroupedRowModel, createSvelteVirtualizer, createSortedRowModel, createSvGrid, getGridCellDomId, sortFns, } from "./index";
1
+ import { applyExcelFilter, applyGroupAggregate, normalizeForFilter, createColumnVirtualizer, createCoreRowModel, createExpandedRowModel, createFilteredRowModel, createGroupedRowModel, createTreeRowModel, createSvelteVirtualizer, createSortedRowModel, createSvGrid, getGridCellDomId, sortFns, } from "./index";
2
2
  import { createRowScrollScaling, resolveMaxDomHeight, } from "./virtualization/scroll-scaling";
3
3
  import "./sv-grid-scrollbar";
4
4
  import { computeColumnStat, formatsNeedingStats, formatNeedsStats, } from "./conditional-formatting";
@@ -17,6 +17,7 @@ import { createAlignedGrids, } from "./aligned-grids";
17
17
  import { resolveColumnTypes, } from "./column-types";
18
18
  import { computeColumnGroupMeta, hiddenLeavesForCollapse, } from "./column-groups";
19
19
  import { resolveColumnId } from "./column-id";
20
+ import { buildAutoGroupColumns, insertGroupFooters, paginateGroupedRows } from "./group-display";
20
21
  import { createGridApi, } from "./build-api";
21
22
  import { createClipboard, } from "./clipboard";
22
23
  import { resolveGridMessages } from "./grid-messages";
@@ -463,6 +464,10 @@ export function createSvGridController(rawProps, domIdBase) {
463
464
  // consumer. Reactive (unlike sort/filter) so pageIndex/rowCount can change.
464
465
  const externalPaginationEnabled = $derived(props.externalPagination === true);
465
466
  const passthroughSortedRowModel = ({ rows }) => rows;
467
+ // Client tree data. Structural like external sort, so captured once at mount -
468
+ // the row-model pipeline is read before any later prop change could apply.
469
+ // svelte-ignore state_referenced_locally
470
+ const treeDataConfig = props.treeData;
466
471
  const grid = createSvGrid({
467
472
  get _features() {
468
473
  return resolveEffectiveFeatures();
@@ -481,7 +486,14 @@ export function createSvGridController(rawProps, domIdBase) {
481
486
  sortedRowModel: externalSortEnabled
482
487
  ? passthroughSortedRowModel
483
488
  : createSortedRowModel(sortFns),
484
- groupedRowModel: createGroupedRowModel(),
489
+ // Tree nesting replaces grouping when `treeData` is set - a row cannot
490
+ // be both a hierarchy node and bucketed under a group banner.
491
+ groupedRowModel: treeDataConfig
492
+ ? createTreeRowModel({
493
+ parentField: treeDataConfig.parentField,
494
+ idField: treeDataConfig.idField,
495
+ })
496
+ : createGroupedRowModel(),
485
497
  expandedRowModel: createExpandedRowModel(),
486
498
  };
487
499
  },
@@ -559,12 +571,108 @@ export function createSvGridController(rawProps, domIdBase) {
559
571
  lastSeededOrder = incoming;
560
572
  userColumnOrder = props.columnOrder ? [...props.columnOrder] : [];
561
573
  });
574
+ // Declared here rather than beside the other pagination/state derivations:
575
+ // the auto-group column pipeline below reads it, and a `const` used above its
576
+ // declaration is a type error even though `$derived` is lazy enough at runtime.
577
+ const groupingColumns = $derived.by(() => {
578
+ gridStateVersion;
579
+ return grid.getState().grouping ?? [];
580
+ });
581
+ /**
582
+ * Row-grouping display mode. `groupRows` (the default) renders a full-width
583
+ * banner per group - the historical behaviour. `singleColumn` folds every
584
+ * grouping level into one synthetic column; `multipleColumns` gives each
585
+ * grouped field its own. Both hide the grouped SOURCE columns, since their
586
+ * values now live in the auto column.
587
+ */
588
+ const groupDisplayMode = $derived(props.groupDisplayMode ?? "groupRows");
589
+ const autoGroupSpec = $derived(buildAutoGroupColumns(groupingColumns, groupDisplayMode));
590
+ /** True when group state is shown in columns rather than banner rows. */
591
+ const groupColumnMode = $derived(autoGroupSpec.autoColumns.length > 0);
592
+ /**
593
+ * The synthetic auto-group columns as real `Column` objects, so the rest of
594
+ * the pipeline (widths, pinning, virtualization, headers) treats them like
595
+ * any other column. They carry no `field` - their cell content is resolved
596
+ * from the row's group state at render time.
597
+ */
598
+ const autoGroupColumns = $derived.by(() => {
599
+ if (!groupColumnMode)
600
+ return [];
601
+ const sourceById = new Map(grid.getAllColumns().map((c) => [c.id, c]));
602
+ return autoGroupSpec.autoColumns.map((spec) => {
603
+ const source = spec.field ? sourceById.get(spec.field) : undefined;
604
+ const sourceHeader = source?.columnDef.header;
605
+ const header = spec.field == null
606
+ ? (props.autoGroupColumnHeader ?? "Group")
607
+ : typeof sourceHeader === "string"
608
+ ? sourceHeader
609
+ : spec.field;
610
+ const column = {
611
+ id: spec.id,
612
+ depth: 0,
613
+ columnDef: {
614
+ header,
615
+ width: props.autoGroupColumnWidth ?? 220,
616
+ sortable: false,
617
+ filterable: false,
618
+ },
619
+ getCanSort: () => false,
620
+ getCanFilter: () => false,
621
+ getIsSorted: () => false,
622
+ getToggleSortingHandler: () => () => { },
623
+ };
624
+ return column;
625
+ });
626
+ });
627
+ /**
628
+ * What an auto-group column shows for a given row.
629
+ *
630
+ * `singleColumn` puts every level in one column, so a group row renders its
631
+ * own level (indented by depth) and leaf rows stay blank - the ancestry is
632
+ * already visible in the banner rows above.
633
+ *
634
+ * `multipleColumns` gives each grouped field a column, so a group row only
635
+ * writes into the column matching ITS level and leaves the others blank.
636
+ *
637
+ * Returns null when the cell should render nothing.
638
+ */
639
+ function autoGroupCell(row, columnId) {
640
+ if (!isGroupRow(row))
641
+ return null;
642
+ const depth = row.depth ?? 0;
643
+ if (columnId !== "__autoGroup") {
644
+ // multipleColumns: only the column for this row's own level renders.
645
+ const field = groupingColumns[depth];
646
+ if (!field || `__group_${field}` !== columnId)
647
+ return null;
648
+ }
649
+ const groupingColumnId = groupingColumns[depth] ?? "";
650
+ const sourceColumn = grid
651
+ .getAllColumns()
652
+ .find((c) => c.id === groupingColumnId);
653
+ const raw = row.getCellValueByColumnId(groupingColumnId);
654
+ const label = sourceColumn
655
+ ? formatCellValue(sourceColumn, raw, row)
656
+ : String(raw ?? "");
657
+ return {
658
+ label,
659
+ count: row.leafCount ?? row.subRows?.length ?? 0,
660
+ // singleColumn indents by level; multipleColumns already separates them.
661
+ depth: columnId === "__autoGroup" ? depth : 0,
662
+ };
663
+ }
562
664
  const allColumns = $derived.by(() => {
563
665
  let raw = grid
564
666
  .getAllColumns()
565
667
  .filter((column) => !hiddenColumns[column.id] &&
566
668
  !hiddenByGroupCollapse[column.id] &&
567
- !isHiddenByResponsive(column));
669
+ !isHiddenByResponsive(column) &&
670
+ // In a column display mode the grouped columns are folded into the
671
+ // auto column(s), so showing them too would just duplicate the value.
672
+ !autoGroupSpec.hiddenSourceIds.has(column.id));
673
+ // Auto-group columns lead, like a row header.
674
+ if (autoGroupColumns.length)
675
+ raw = [...autoGroupColumns, ...raw];
568
676
  // Apply user reorder (if any). Unknown ids in userColumnOrder are
569
677
  // skipped; columns not in userColumnOrder keep their original
570
678
  // relative order after the user-ordered ones.
@@ -609,8 +717,21 @@ export function createSvGridController(rawProps, domIdBase) {
609
717
  const headers = [];
610
718
  for (const column of allColumns) {
611
719
  const header = byId.get(column.id);
612
- if (header)
720
+ if (header) {
613
721
  headers.push(header);
722
+ continue;
723
+ }
724
+ // Auto-group columns have no engine header. Synthesize one rather than
725
+ // skipping: the header row indexes `headers` BY POSITION against the
726
+ // rendered columns, so a gap would shift every later header by one.
727
+ const synthetic = {
728
+ id: column.id,
729
+ isPlaceholder: false,
730
+ colSpan: 1,
731
+ column,
732
+ getContext: () => ({ header: synthetic, column, table: grid }),
733
+ };
734
+ headers.push(synthetic);
614
735
  }
615
736
  return [{ id: base[0].id, headers }];
616
737
  });
@@ -814,10 +935,6 @@ export function createSvGridController(rawProps, domIdBase) {
814
935
  directions[column.id] = column.getIsSorted();
815
936
  return directions;
816
937
  });
817
- const groupingColumns = $derived.by(() => {
818
- gridStateVersion;
819
- return grid.getState().grouping ?? [];
820
- });
821
938
  const paginationState = $derived.by(() => {
822
939
  gridStateVersion;
823
940
  return grid.getState().pagination ?? { pageIndex: 0, pageSize: 10 };
@@ -917,15 +1034,132 @@ export function createSvGridController(rawProps, domIdBase) {
917
1034
  * the full dataset rather than the current page (see the comment above
918
1035
  * `_rowModels`).
919
1036
  */
920
- const allRows = $derived.by(() => {
921
- const rows = allRowsBeforePagination;
922
- // External pagination: `data` already IS the current page - never slice.
1037
+ /**
1038
+ * Grouped pagination. `allRowsBeforePagination` interleaves group banners with
1039
+ * data rows, so a plain slice spends the page budget on banners - ten groups of
1040
+ * two at `pageSize: 10` used to show about three data rows (#73). When grouping
1041
+ * is active we page by DATA rows and reprint each page's ancestor banners.
1042
+ * Null when grouping is off, so the flat path stays a cheap slice.
1043
+ */
1044
+ const groupedPage = $derived.by(() => {
923
1045
  if (!paginationEnabled || externalPaginationEnabled)
924
- return rows;
1046
+ return null;
1047
+ if (!groupingColumns.length)
1048
+ return null;
925
1049
  const { pageIndex, pageSize } = paginationState;
926
- const start = pageIndex * pageSize;
927
- return rows.slice(start, start + pageSize);
1050
+ return paginateGroupedRows(allRowsBeforePagination, {
1051
+ getDepth: (row) => row.depth ?? 0,
1052
+ isGroup: (row) => isGroupRow(row),
1053
+ // A collapsed group is the visible unit and takes a page slot; an expanded
1054
+ // one is a banner reprinted above its children.
1055
+ isExpanded: (row) => row.getIsExpanded?.() === true,
1056
+ pageIndex,
1057
+ pageSize,
1058
+ });
928
1059
  });
1060
+ /**
1061
+ * The grand-total row: one synthetic row carrying each column's `aggregate`
1062
+ * over the whole filtered set. Shaped like a group footer so it renders
1063
+ * through the normal cell path - totals land under the columns they belong
1064
+ * to, and `getCanExpand`/`getIsExpanded` are off so nothing treats it as a
1065
+ * banner. Returns null when no column aggregates (nothing to total).
1066
+ *
1067
+ * Aggregates the LEAF rows: `allRowsBeforePagination` interleaves group
1068
+ * banners once grouping is on, and those carry per-group subtotals that
1069
+ * would otherwise be summed a second time.
1070
+ */
1071
+ const buildGrandTotalRow = () => {
1072
+ const leaves = allRowsBeforePagination.filter((row) => !isGroupRow(row));
1073
+ const columns = grid.getAllColumns();
1074
+ const original = {};
1075
+ let hasAggregate = false;
1076
+ for (const column of columns) {
1077
+ const field = column.columnDef.field;
1078
+ const agg = column.columnDef.aggregate;
1079
+ if (!field || !agg)
1080
+ continue;
1081
+ hasAggregate = true;
1082
+ original[field] = applyGroupAggregate(agg, column.id, leaves);
1083
+ }
1084
+ if (!hasAggregate)
1085
+ return null;
1086
+ return {
1087
+ id: "__grand_total__",
1088
+ index: -1,
1089
+ original,
1090
+ depth: 0,
1091
+ subRows: [],
1092
+ leafCount: leaves.length,
1093
+ __groupFooter: true,
1094
+ __grandTotal: true,
1095
+ getCanExpand: () => false,
1096
+ getIsExpanded: () => false,
1097
+ toggleExpanded: () => { },
1098
+ getIsSelected: () => false,
1099
+ toggleSelected: () => { },
1100
+ getAllCells: () => [],
1101
+ getCellValueByColumnId: (columnId) => {
1102
+ const col = columns.find((c) => c.id === columnId);
1103
+ const field = col?.columnDef.field;
1104
+ return field && field in original ? original[field] : undefined;
1105
+ },
1106
+ };
1107
+ };
1108
+ /**
1109
+ * Visible rows for the current page. Applied last so filters operate on
1110
+ * the full dataset rather than the current page (see the comment above
1111
+ * `_rowModels`).
1112
+ */
1113
+ const allRows = $derived.by(() => {
1114
+ const paged = (() => {
1115
+ const rows = allRowsBeforePagination;
1116
+ // External pagination: `data` already IS the current page - never slice.
1117
+ if (!paginationEnabled || externalPaginationEnabled)
1118
+ return rows;
1119
+ if (groupedPage)
1120
+ return groupedPage.rows;
1121
+ const { pageIndex, pageSize } = paginationState;
1122
+ const start = pageIndex * pageSize;
1123
+ return rows.slice(start, start + pageSize);
1124
+ })();
1125
+ const wantGroupFooters = Boolean(props.groupFooters) && groupingColumns.length > 0;
1126
+ // The grand total belongs at the END of the dataset, so it is appended only
1127
+ // on the last page - otherwise every page would end in a "total" that isn't
1128
+ // one. With pagination off (or external, where the consumer owns paging and
1129
+ // `data` is already the page) there is only one page to be last.
1130
+ const isLastPage = (() => {
1131
+ if (!paginationEnabled || externalPaginationEnabled)
1132
+ return true;
1133
+ const { pageIndex, pageSize } = paginationState;
1134
+ const total = groupedPage ? groupedPage.dataRowCount : allRowsBeforePagination.length;
1135
+ return pageIndex >= Math.ceil(total / pageSize) - 1;
1136
+ })();
1137
+ const wantGrandTotal = Boolean(props.grandTotalRow) && isLastPage;
1138
+ if (!wantGroupFooters && !wantGrandTotal)
1139
+ return paged;
1140
+ // Footers are inserted AFTER paging so they never eat page budget. A footer
1141
+ // is a clone of its group row, which already carries the group's aggregate
1142
+ // values - so it renders through the normal cell path and the totals land
1143
+ // under the columns they belong to, with `getCanExpand` off so nothing
1144
+ // treats it as a banner.
1145
+ return insertGroupFooters(paged, {
1146
+ getDepth: (row) => row.depth ?? 0,
1147
+ isGroup: (row) => isGroupRow(row),
1148
+ includeGroupFooter: wantGroupFooters,
1149
+ includeGrandTotalFooter: wantGrandTotal,
1150
+ makeGrandTotal: buildGrandTotalRow,
1151
+ makeFooter: (group) => ({
1152
+ ...group,
1153
+ id: `${group.id}__footer`,
1154
+ subRows: [],
1155
+ __groupFooter: true,
1156
+ getCanExpand: () => false,
1157
+ getIsExpanded: () => false,
1158
+ }),
1159
+ });
1160
+ });
1161
+ /** Rows the pager divides into pages: data rows only once grouping is on. */
1162
+ const paginatedRowTotal = $derived(groupedPage ? groupedPage.dataRowCount : allRowsBeforePagination.length);
929
1163
  // When a filter reduces the dataset, the stored pageIndex can point beyond
930
1164
  // the last valid page. Reset to page 0 so the grid never shows a blank body.
931
1165
  // Skipped for external pagination, where the consumer owns pageIndex.
@@ -933,14 +1167,14 @@ export function createSvGridController(rawProps, domIdBase) {
933
1167
  if (!paginationEnabled || externalPaginationEnabled)
934
1168
  return;
935
1169
  const { pageIndex, pageSize } = paginationState;
936
- const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1170
+ const pageCount = Math.ceil(paginatedRowTotal / pageSize);
937
1171
  if (pageCount > 0 && pageIndex >= pageCount) {
938
1172
  grid.setPagination({ pageIndex: 0, pageSize });
939
1173
  }
940
1174
  });
941
1175
  // Footer-facing pagination values. In external mode they come from the
942
1176
  // consumer-controlled props; otherwise from the local row model + state.
943
- const paginationTotalRows = $derived(externalPaginationEnabled ? (props.rowCount ?? 0) : allRowsBeforePagination.length);
1177
+ const paginationTotalRows = $derived(externalPaginationEnabled ? (props.rowCount ?? 0) : paginatedRowTotal);
944
1178
  const paginationPageIndex = $derived(externalPaginationEnabled ? (props.pageIndex ?? 0) : paginationState.pageIndex);
945
1179
  const paginationPageSize = $derived(externalPaginationEnabled ? (props.pageSize ?? 10) : paginationState.pageSize);
946
1180
  const rowSelectionState = $derived.by(() => {
@@ -1893,6 +2127,72 @@ export function createSvGridController(rawProps, domIdBase) {
1893
2127
  el.addEventListener("scroll-change", onHorizontal);
1894
2128
  return () => el.removeEventListener("scroll-change", onHorizontal);
1895
2129
  });
2130
+ // --- Auto row height -------------------------------------------------
2131
+ // Measured natural heights, keyed by row index. A plain Map (not $state):
2132
+ // it is written from a ResizeObserver during layout, and making it reactive
2133
+ // would re-run the render that is doing the measuring. `autoRowHeightVersion`
2134
+ // is the reactive signal instead, bumped only when a height actually changes.
2135
+ const measuredRowHeights = new Map();
2136
+ let autoRowHeightVersion = $state(0);
2137
+ // A per-row `rowHeight` function already supplies heights, so auto-measuring
2138
+ // would fight it. Fixed numbers are fine - they become the pre-measure estimate.
2139
+ const autoRowHeightOn = $derived(props.autoRowHeight === true && typeof props.rowHeight !== "function");
2140
+ const autoRowHeightFallback = $derived(typeof props.rowHeight === "number" ? props.rowHeight : 30);
2141
+ /** Report a row's measured height. Called by the view's measuring action.
2142
+ * Sub-pixel churn is ignored so a fractional layout can't loop. */
2143
+ function reportRowHeight(index, height) {
2144
+ if (!autoRowHeightOn || height <= 0)
2145
+ return;
2146
+ const prev = measuredRowHeights.get(index);
2147
+ if (prev !== undefined && Math.abs(prev - height) < 1)
2148
+ return;
2149
+ measuredRowHeights.set(index, height);
2150
+ autoRowHeightVersion += 1;
2151
+ }
2152
+ /**
2153
+ * Svelte action for a `<tr>` under `autoRowHeight`. Measures the row once on
2154
+ * mount and again whenever its content reflows (a resized column rewraps
2155
+ * text), reporting the natural height back to the virtualizer.
2156
+ *
2157
+ * Uses the shared rAF-deferred observer: the callback bumps state that
2158
+ * re-lays-out the observed element, which is exactly the pattern that trips
2159
+ * "ResizeObserver loop completed with undelivered notifications" when handled
2160
+ * synchronously.
2161
+ */
2162
+ function measureRowHeight(node, index) {
2163
+ let current = index;
2164
+ let stop = null;
2165
+ const measure = () => reportRowHeight(current, node.getBoundingClientRect().height);
2166
+ const attach = () => {
2167
+ stop?.();
2168
+ stop = autoRowHeightOn ? observeSizeRaf(node, measure) : null;
2169
+ if (autoRowHeightOn)
2170
+ measure();
2171
+ };
2172
+ attach();
2173
+ return {
2174
+ update(next) {
2175
+ if (next === current)
2176
+ return;
2177
+ current = next;
2178
+ // A recycled row now shows different data - re-measure under its new index.
2179
+ measure();
2180
+ },
2181
+ destroy() {
2182
+ stop?.();
2183
+ },
2184
+ };
2185
+ }
2186
+ // Row index -> row identity changes when the data does, so stale measurements
2187
+ // would size new rows by the old ones. Drop them when the row set changes.
2188
+ $effect(() => {
2189
+ void allRows.length;
2190
+ void internalData;
2191
+ if (!autoRowHeightOn || measuredRowHeights.size === 0)
2192
+ return;
2193
+ measuredRowHeights.clear();
2194
+ autoRowHeightVersion += 1;
2195
+ });
1896
2196
  $effect(() => {
1897
2197
  // When containerHeight is a string (e.g. "100%") the actual pixel height
1898
2198
  // depends on the parent layout - read it from the live scroll container.
@@ -1905,9 +2205,18 @@ export function createSvGridController(rawProps, domIdBase) {
1905
2205
  ? (scrollContainer?.clientHeight ?? 520)
1906
2206
  : (props.containerHeight ?? 520);
1907
2207
  const rh = props.rowHeight;
2208
+ // Auto height: read measured sizes, falling back to the fixed rowHeight as
2209
+ // the estimate for rows that have not rendered yet. Touch the version so a
2210
+ // new measurement re-runs this effect (the Map itself is not reactive).
2211
+ autoRowHeightVersion;
2212
+ const estimateSize = autoRowHeightOn
2213
+ ? (index) => measuredRowHeights.get(index) ?? autoRowHeightFallback
2214
+ : typeof rh === "function"
2215
+ ? rh
2216
+ : (rh ?? 30);
1908
2217
  virtualizer.setOptions({
1909
2218
  count: allRows.length,
1910
- estimateSize: typeof rh === "function" ? rh : (rh ?? 30),
2219
+ estimateSize,
1911
2220
  overscan: props.overscan ?? 8,
1912
2221
  viewportHeight,
1913
2222
  });
@@ -1967,6 +2276,20 @@ export function createSvGridController(rawProps, domIdBase) {
1967
2276
  * are resolved on every call because they can change as other cells in
1968
2277
  * the same row change (the whole point of cascading editors). */
1969
2278
  const editorOptionsCache = {};
2279
+ // Resolved async `editorOptions`, keyed by column id (static source) or
2280
+ // `columnId::rowId` (per-row cascade). Reactive so a fetch landing re-renders
2281
+ // the open editor; `...Pending` both drives the loading state and dedupes the
2282
+ // request across the many synchronous render calls.
2283
+ let asyncEditorOptions = $state({});
2284
+ // Deliberately NOT reactive: this is written during render (the resolver runs
2285
+ // inside the template), and mutating $state there is a Svelte 5 error that
2286
+ // takes the whole editor down with it. Only the resolved result needs to be
2287
+ // reactive; the loading flag is derived from "thenable source, no result yet".
2288
+ const asyncEditorOptionsPending = new Set();
2289
+ // Columns whose per-row `editorOptions` has been observed returning a Promise.
2290
+ // Lets the resolver answer from cache without re-invoking the source, so an
2291
+ // async source fires one request per row instead of one per render.
2292
+ const asyncEditorColumns = new Set();
1970
2293
  const headerSelectionState = $derived.by(() => {
1971
2294
  gridStateVersion;
1972
2295
  const selectable = allRows.filter((row) => !isGroupRow(row));
@@ -2503,6 +2826,9 @@ export function createSvGridController(rawProps, domIdBase) {
2503
2826
  get isCellEditableAt() { return isCellEditableAt; },
2504
2827
  get sortDirectionByColumn() { return sortDirectionByColumn; },
2505
2828
  get groupingColumns() { return groupingColumns; },
2829
+ get groupDisplayMode() { return groupDisplayMode; },
2830
+ get groupColumnMode() { return groupColumnMode; },
2831
+ get autoGroupCell() { return autoGroupCell; },
2506
2832
  get paginationState() { return paginationState; },
2507
2833
  get externalPaginationEnabled() { return externalPaginationEnabled; },
2508
2834
  get paginationTotalRows() { return paginationTotalRows; },
@@ -2677,6 +3003,15 @@ export function createSvGridController(rawProps, domIdBase) {
2677
3003
  get rowVirtualizationEnabled() { return rowVirtualizationEnabled; },
2678
3004
  get columnVirtualizationEnabled() { return columnVirtualizationEnabled; },
2679
3005
  get virtualRows() { return virtualRows; },
3006
+ get autoRowHeightOn() { return autoRowHeightOn; },
3007
+ /** Svelte action: measure this row and report its natural height. */
3008
+ get measureRowHeight() { return measureRowHeight; },
3009
+ /** A row's measured height, or undefined before it has been measured.
3010
+ * The non-virtualized body reads this directly (it has no virtual item). */
3011
+ get measuredRowHeightPx() {
3012
+ autoRowHeightVersion;
3013
+ return (index) => measuredRowHeights.get(index);
3014
+ },
2680
3015
  get virtualRowTotalSize() { return virtualRowTotalSize; },
2681
3016
  get virtualRowStart() { return virtualRowStart; },
2682
3017
  get virtualRowEnd() { return virtualRowEnd; },
@@ -2718,7 +3053,12 @@ export function createSvGridController(rawProps, domIdBase) {
2718
3053
  get getCellDisplayValue() { return getCellDisplayValue; },
2719
3054
  get getColumnAlign() { return getColumnAlign; },
2720
3055
  get editorOptionsCache() { return editorOptionsCache; },
3056
+ get asyncEditorOptions() { return asyncEditorOptions; },
3057
+ set asyncEditorOptions(v) { asyncEditorOptions = v; },
3058
+ get asyncEditorOptionsPending() { return asyncEditorOptionsPending; },
3059
+ get asyncEditorColumns() { return asyncEditorColumns; },
2721
3060
  get getColumnEditorOptions() { return getColumnEditorOptions; },
3061
+ get areEditorOptionsLoading() { return areEditorOptionsLoading; },
2722
3062
  get formatListCellValue() { return formatListCellValue; },
2723
3063
  get formatCellValue() { return formatCellValue; },
2724
3064
  get getPinnedCellValue() { return getPinnedCellValue; },
@@ -2854,7 +3194,7 @@ export function createSvGridController(rawProps, domIdBase) {
2854
3194
  const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard(ctx);
2855
3195
  const { computeSummaries, hasRenderedColumn } = createSummaries(ctx);
2856
3196
  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(ctx);
2857
- const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender(ctx);
3197
+ const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, areEditorOptionsLoading, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender(ctx);
2858
3198
  const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing(ctx);
2859
3199
  const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection(ctx);
2860
3200
  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(ctx);