@svgrid/grid 3.0.5 → 3.0.7

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 (166) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +23 -3
  4. package/dist/SvGrid.controller.svelte.js +235 -54
  5. package/dist/SvGrid.css +143 -7
  6. package/dist/SvGrid.helpers.js +2 -4
  7. package/dist/SvGrid.svelte +223 -38
  8. package/dist/SvGrid.types.d.ts +77 -4
  9. package/dist/SvGridDropdown.svelte +20 -0
  10. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  11. package/dist/SvModal.svelte +22 -0
  12. package/dist/SvModal.svelte.d.ts +7 -0
  13. package/dist/a11y/dismissable.d.ts +10 -0
  14. package/dist/a11y/dismissable.js +24 -2
  15. package/dist/build-api.js +81 -31
  16. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-BgUS-W3o.js} +151 -142
  17. package/dist/cdn/GridMenus-CcwU8I4t.js +644 -0
  18. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  19. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  21. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-9yz-SbdZ.js} +2 -2
  23. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-D7-ql1Hd.js} +2 -2
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DMUOFxOe.js} +4 -4
  25. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-N7ik0oWy.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-CB_Jnm2k.js} +4 -4
  27. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-Ca7gF8ZK.js} +4 -4
  28. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-9GOJRHP7.js} +5 -5
  29. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-Dl-FujN8.js} +5 -5
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-B1LFgoPP.js} +2 -2
  31. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-By3n7DGZ.js} +2 -2
  32. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  33. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  34. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  35. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  36. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-Mnv84zoE.js} +28 -6
  37. package/dist/cdn/{chart-Dz7SqMXH.js → chart-By7h2Guv.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-BC-tOgFY.js} +1 -1
  39. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DUaXr-sb.js} +1 -1
  40. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-BDtrNEiA.js} +1 -1
  41. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-C1aOAPBZ.js} +1 -1
  42. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  43. package/dist/cdn/{editor-registry-BhgE3S84.js → editor-registry-BVeHvlZR.js} +49 -50
  44. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-y1wdyCcv.js} +93 -97
  45. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  46. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  47. package/dist/cdn/{src-jcFMkBnN.js → src-25JmJ_7g.js} +4768 -4149
  48. package/dist/cdn/{src-Bklofsad.js → src-kE9B98r6.js} +4796 -4177
  49. package/dist/cdn/svgrid.js +18 -18
  50. package/dist/cdn/svgrid.svelte-external.js +18 -18
  51. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  52. package/dist/cell-formatting.d.ts +9 -0
  53. package/dist/cell-formatting.js +52 -1
  54. package/dist/cell-render.js +25 -6
  55. package/dist/clipboard.d.ts +2 -2
  56. package/dist/clipboard.js +72 -23
  57. package/dist/columns.js +7 -2
  58. package/dist/conditional-formatting.d.ts +2 -0
  59. package/dist/conditional-formatting.js +2 -0
  60. package/dist/core.d.ts +22 -6
  61. package/dist/core.js +478 -40
  62. package/dist/editing.js +24 -0
  63. package/dist/export-format.d.ts +8 -0
  64. package/dist/export-format.js +23 -16
  65. package/dist/grid-messages.d.ts +4 -0
  66. package/dist/grid-messages.js +4 -0
  67. package/dist/headless.d.ts +5 -0
  68. package/dist/headless.js +12 -0
  69. package/dist/history.d.ts +21 -0
  70. package/dist/history.js +34 -4
  71. package/dist/index.d.ts +1 -1
  72. package/dist/keyboard-handlers.js +24 -1
  73. package/dist/keyboard.js +4 -0
  74. package/dist/row-drag-touch.d.ts +1 -1
  75. package/dist/row-drag.js +35 -1
  76. package/dist/row-model.d.ts +6 -0
  77. package/dist/row-resize.js +22 -1
  78. package/dist/selection.js +1 -1
  79. package/dist/server-block-cache.d.ts +5 -0
  80. package/dist/server-block-cache.js +8 -0
  81. package/dist/server-data-source.d.ts +35 -3
  82. package/dist/server-data-source.js +11 -2
  83. package/dist/server.d.ts +1 -1
  84. package/dist/sparkline.js +7 -5
  85. package/dist/svgrid-wrapper.types.d.ts +33 -0
  86. package/dist/validate.js +9 -1
  87. package/dist/virtualization/column-virtualizer.d.ts +1 -0
  88. package/dist/virtualization/svelte-virtualizer.svelte.d.ts +1 -0
  89. package/dist/virtualization/svelte-virtualizer.svelte.js +1 -0
  90. package/dist/virtualization/virtualizer.d.ts +3 -0
  91. package/dist/virtualization/virtualizer.js +14 -1
  92. package/package.json +6 -1
  93. package/src/GridMenus.svelte +34 -4
  94. package/src/SvGrid.controller.svelte.ts +236 -57
  95. package/src/SvGrid.css +143 -7
  96. package/src/SvGrid.helpers.ts +2 -4
  97. package/src/SvGrid.svelte +223 -38
  98. package/src/SvGrid.types.ts +77 -4
  99. package/src/SvGridDropdown.svelte +20 -0
  100. package/src/SvModal.svelte +22 -0
  101. package/src/SvModal.test.ts +30 -1
  102. package/src/a11y/dismissable.test.ts +10 -1
  103. package/src/a11y/dismissable.ts +22 -2
  104. package/src/build-api.ts +85 -27
  105. package/src/cell-formatting.test.ts +17 -0
  106. package/src/cell-formatting.ts +49 -1
  107. package/src/cell-render.test.ts +24 -2
  108. package/src/cell-render.ts +24 -8
  109. package/src/clipboard.test.ts +107 -0
  110. package/src/clipboard.ts +62 -23
  111. package/src/columns.test.ts +4 -4
  112. package/src/columns.ts +7 -2
  113. package/src/conditional-formatting.test.ts +8 -0
  114. package/src/conditional-formatting.ts +3 -0
  115. package/src/core.sort.test.ts +86 -0
  116. package/src/core.tick-repair.test.ts +287 -0
  117. package/src/core.ts +478 -40
  118. package/src/editing.test.ts +52 -0
  119. package/src/editing.ts +25 -0
  120. package/src/export-dates.test.ts +114 -0
  121. package/src/export-format.ts +26 -14
  122. package/src/grid-messages.test.ts +7 -0
  123. package/src/grid-messages.ts +10 -0
  124. package/src/headless.ts +24 -0
  125. package/src/history.test.ts +49 -1
  126. package/src/history.ts +48 -2
  127. package/src/index.ts +2 -0
  128. package/src/keyboard-handlers.coverage.test.ts +65 -0
  129. package/src/keyboard-handlers.ts +26 -1
  130. package/src/keyboard.ts +3 -0
  131. package/src/row-drag-touch.ts +2 -2
  132. package/src/row-drag.test.ts +51 -0
  133. package/src/row-drag.ts +35 -2
  134. package/src/row-model.ts +6 -0
  135. package/src/row-resize.ts +24 -1
  136. package/src/selection.ts +1 -1
  137. package/src/server-block-cache.test.ts +27 -0
  138. package/src/server-block-cache.ts +13 -0
  139. package/src/server-data-source.infinite.test.ts +62 -0
  140. package/src/server-data-source.test.ts +19 -0
  141. package/src/server-data-source.ts +48 -4
  142. package/src/server.ts +2 -0
  143. package/src/sparkline.test.ts +7 -0
  144. package/src/sparkline.ts +6 -4
  145. package/src/svgrid-wrapper.types.ts +32 -0
  146. package/src/svgrid.api.test.ts +21 -0
  147. package/src/svgrid.charting.test.ts +12 -4
  148. package/src/svgrid.context-menu.test.ts +12 -0
  149. package/src/svgrid.data-push.svelte.test.ts +94 -0
  150. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  151. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  152. package/src/svgrid.gantt-seam.test.ts +1 -1
  153. package/src/svgrid.live-update-paths.svelte.test.ts +186 -0
  154. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  155. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  156. package/src/svgrid.row-pinning.test.ts +15 -1
  157. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  158. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  159. package/src/svgrid.upsell-license.test.ts +1 -1
  160. package/src/test-setup.ts +12 -0
  161. package/src/transaction.test.ts +58 -0
  162. package/src/validate.ts +7 -1
  163. package/src/virtualization/svelte-virtualizer.svelte.ts +1 -0
  164. package/src/virtualization/virtualizer.test.ts +44 -0
  165. package/src/virtualization/virtualizer.ts +13 -1
  166. package/dist/cdn/GridMenus-DLL-qZeu.js +0 -635
@@ -515,7 +515,11 @@ export function createSvGridController<
515
515
  // previously declared it twice and open-coded the push in three places.
516
516
  type HistoryStep = SharedHistoryStep
517
517
  const UNDO_LIMIT = 200
518
- let history = $state<HistoryStep[]>([])
518
+ // Raw: pushHistory always assigns a fresh array and nothing renders from a
519
+ // step, so deep proxies would cost 200 wrapped objects for nothing. It also
520
+ // keeps a consumer's `tag` the object it passed, which a proxy would not be:
521
+ // the sheet shell compares tags by identity.
522
+ let history = $state.raw<HistoryStep[]>([])
519
523
  /** Index in `history` of the LAST applied step. -1 means "nothing applied".
520
524
  * undo() decrements; redo() increments. New edits truncate everything
521
525
  * past the pointer (the classic "you can't redo after editing" rule). */
@@ -528,6 +532,9 @@ export function createSvGridController<
528
532
  * $state: it is written and read synchronously inside a single call and
529
533
  * nothing renders from it. */
530
534
  let historyGroupId: string | undefined = undefined
535
+ /** The consumer's mark for the steps recorded from now on (`api.setHistoryTag`);
536
+ * not $state for the same reason. */
537
+ let historyTag: unknown = undefined
531
538
 
532
539
  // ---- Hover tooltip (custom popover, not native title=) ---------------
533
540
  // Triggered by per-column `tooltip` field OR per-cell `notes` prop.
@@ -599,7 +606,25 @@ export function createSvGridController<
599
606
  // catch-all gridStateVersion, so navigation doesn't rebuild 1M rows.
600
607
  let dataStateVersion = $state(0);
601
608
  const selectionColumnWidth = 44;
609
+ const detailToggleColumnWidth = 36;
602
610
  const rowNumberColumnWidth = $derived(props.rowNumberWidth ?? 56);
611
+ const showDetailToggleEffective = $derived(props.showDetailToggle === true);
612
+ /**
613
+ * What the chevron on a row does, or null when the row has no detail to
614
+ * open: a detail row itself, a group or placeholder on a server model, a
615
+ * row `hasDetail` rules out, or a grid with nothing to call.
616
+ */
617
+ function detailToggleOf(row: Row<TData>, rowIndex: number): { open: boolean; toggle: () => void } | null {
618
+ const data = row.original;
619
+ if (props.isDetailRow?.(data, rowIndex)) return null;
620
+ const sg = props.serverGroup;
621
+ if (sg) {
622
+ if (!sg.toggleDetail || sg.isGroup(data) || (sg.hasDetail && !sg.hasDetail(data))) return null;
623
+ return { open: sg.detailOpen?.(data) ?? false, toggle: () => sg.toggleDetail!(data) };
624
+ }
625
+ if (!props.onDetailToggle || (props.hasDetail && !props.hasDetail(data))) return null;
626
+ return { open: props.isDetailOpen?.(data) ?? false, toggle: () => props.onDetailToggle!(data, rowIndex) };
627
+ }
603
628
  // A per-row `rowHeight` function already supplies heights, so auto-measuring
604
629
  // would fight it. Fixed numbers are fine - they become the pre-measure estimate.
605
630
  const autoRowHeightOn = $derived(
@@ -771,6 +796,16 @@ export function createSvGridController<
771
796
  ((props.selectionMode ?? "both") === "row" ||
772
797
  (props.selectionMode ?? "both") === "both"),
773
798
  );
799
+ /** The system columns to the left of the data: row numbers, selection, the detail toggle. */
800
+ const systemColumnsWidth = $derived(
801
+ (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
802
+ (showRowSelectionEffective ? selectionColumnWidth : 0) +
803
+ (showDetailToggleEffective ? detailToggleColumnWidth : 0),
804
+ );
805
+ /** Where the detail-toggle column sticks: after the row numbers and the selection column. */
806
+ const detailToggleColumnLeft = $derived(
807
+ (showRowNumbersEffective ? rowNumberColumnWidth : 0) + (showRowSelectionEffective ? selectionColumnWidth : 0),
808
+ );
774
809
  // `selectable` is the shortcut alias, so it wins over the fine-grained prop,
775
810
  // which in turn wins over `selectionMode` - the same precedence the other
776
811
  // shortcuts use. Unlike them it starts ON (selectionMode defaults to 'both'),
@@ -810,7 +845,18 @@ export function createSvGridController<
810
845
  // `data` is optional on the type (a `rowModel` may supply the rows
811
846
  // instead), so every read falls back to empty rather than to undefined.
812
847
  const EMPTY_DATA: ReadonlyArray<TData> = [];
813
- let internalData = $state.raw<ReadonlyArray<TData>>(props.data ?? EMPTY_DATA);
848
+ // Seeded as a copy, for the same reason the sync effect below makes one: the
849
+ // row model tells data apart by array identity, so the grid has to own an
850
+ // array the consumer cannot grow behind its back.
851
+ let internalData = $state.raw<ReadonlyArray<TData>>(
852
+ props.data?.length ? props.data.slice() : EMPTY_DATA,
853
+ );
854
+ // What the last sync saw, so the effect can tell a replacement (drop the
855
+ // cell edits) from a push or splice (keep them) and skip a no-op run.
856
+ // svelte-ignore state_referenced_locally
857
+ let lastData: ReadonlyArray<TData> = props.data ?? EMPTY_DATA;
858
+ // svelte-ignore state_referenced_locally
859
+ let lastDataLength = props.data?.length ?? 0;
814
860
  // Resolve `cellDataType` / `inferColumnTypes` into concrete editorType +
815
861
  // format defaults once, up front, so every downstream reader sees a normal
816
862
  // column. Explicit fields on the ColumnDef always win.
@@ -858,12 +904,27 @@ export function createSvGridController<
858
904
  }
859
905
 
860
906
  $effect(() => {
907
+ const next = props.data ?? EMPTY_DATA;
908
+ // Reading `length` is what subscribes this effect to a `$state` array's own
909
+ // length signal, so `rows.push(...)` and `rows.splice(...)` re-run it.
910
+ // Without this read nothing in the grid tracks the row count at all:
911
+ // `getRowModel` reads `.length` only inside its cache miss, and the miss is
912
+ // keyed on the array reference, which a mutation does not change.
913
+ const length = next.length;
914
+ const replaced = next !== lastData;
915
+ if (!replaced && length === lastDataLength) return;
916
+ lastData = next;
917
+ lastDataLength = length;
918
+ // A fresh array object, because `getRowModel` memoises on identity. The
919
+ // elements are the same objects, so a row that did not move keeps its row
920
+ // object and a field write still reaches its cell through the proxy.
921
+ internalData = length === 0 ? EMPTY_DATA : next.slice();
861
922
  // When the consumer replaces `data` (e.g. a "Reset" button), drop any
862
923
  // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
863
924
  // would keep returning the old edited values from `editedCellValues`
864
- // even though the underlying data has been replaced.
865
- internalData = props.data ?? EMPTY_DATA;
866
- editedCellValues = {};
925
+ // even though the underlying data has been replaced. Growing or shrinking
926
+ // the array is not a reset, so it keeps them.
927
+ if (replaced) editedCellValues = {};
867
928
  });
868
929
  $effect(() => {
869
930
  internalColumns = resolveCols(props.columns);
@@ -890,36 +951,42 @@ export function createSvGridController<
890
951
  // svelte-ignore state_referenced_locally
891
952
  const treeDataConfig = props.treeData;
892
953
 
954
+ // Pagination is intentionally NOT in the grid's row-model pipeline. The
955
+ // wrapper applies its own filters (filterMenuValues, globalFilter,
956
+ // valueFilters) on top of `grid.getRowModel().rows`. If pagination ran
957
+ // first, those filters would only see the visible page. Instead the wrapper
958
+ // paginates last - see `allRows` below.
959
+ //
960
+ // Built ONCE. Both inputs are captured at mount, and the stages keep state
961
+ // between runs: the sorted and filtered stages hold their last output so a
962
+ // data tick can repair it instead of recomputing (core.ts, `replacedRowsOf`).
963
+ // This used to be a getter that built fresh stages on every read, which
964
+ // threw that cache away on each run - a live feed re-sorted 100k rows on
965
+ // every tick and the row-model memo's `cachedPipeline` never matched.
966
+ const rowModels = {
967
+ coreRowModel: createCoreRowModel<TData>(),
968
+ filteredRowModel: createFilteredRowModel<TData>(),
969
+ // External-sort mode: pass the rows through untouched so the consumer
970
+ // controls ordering (e.g. tree data that must preserve hierarchy).
971
+ sortedRowModel: externalSortEnabled
972
+ ? passthroughSortedRowModel
973
+ : createSortedRowModel<TData>(sortFns),
974
+ // Tree nesting replaces grouping when `treeData` is set - a row cannot
975
+ // be both a hierarchy node and bucketed under a group banner.
976
+ groupedRowModel: treeDataConfig
977
+ ? createTreeRowModel<TData>({
978
+ parentField: treeDataConfig.parentField,
979
+ idField: treeDataConfig.idField,
980
+ })
981
+ : createGroupedRowModel<TData>(),
982
+ expandedRowModel: createExpandedRowModel<TData>(),
983
+ };
893
984
 
894
985
  const grid = createSvGrid({
895
986
  get _features() {
896
987
  return resolveEffectiveFeatures();
897
988
  },
898
- get _rowModels() {
899
- // Pagination is intentionally NOT in the grid's row-model pipeline.
900
- // The wrapper applies its own filters (filterMenuValues, globalFilter,
901
- // valueFilters) on top of `grid.getRowModel().rows`. If pagination
902
- // ran first, those filters would only see the visible page. Instead
903
- // the wrapper paginates last - see `allRows` below.
904
- return {
905
- coreRowModel: createCoreRowModel<TData>(),
906
- filteredRowModel: createFilteredRowModel<TData>(),
907
- // External-sort mode: pass the rows through untouched so the consumer
908
- // controls ordering (e.g. tree data that must preserve hierarchy).
909
- sortedRowModel: externalSortEnabled
910
- ? passthroughSortedRowModel
911
- : createSortedRowModel<TData>(sortFns),
912
- // Tree nesting replaces grouping when `treeData` is set - a row cannot
913
- // be both a hierarchy node and bucketed under a group banner.
914
- groupedRowModel: treeDataConfig
915
- ? createTreeRowModel<TData>({
916
- parentField: treeDataConfig.parentField,
917
- idField: treeDataConfig.idField,
918
- })
919
- : createGroupedRowModel<TData>(),
920
- expandedRowModel: createExpandedRowModel<TData>(),
921
- };
922
- },
989
+ _rowModels: rowModels,
923
990
  get columns() {
924
991
  return internalColumns;
925
992
  },
@@ -1384,10 +1451,8 @@ export function createSvGridController<
1384
1451
 
1385
1452
  /** Cumulative pixel offsets for left- and right-pinned columns. */
1386
1453
  const pinnedOffsets = $derived.by(function pinnedOffsets_d() {
1387
- const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
1388
- const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
1389
1454
  const left: Record<string, number> = {};
1390
- let leftAcc = rowNumberWidth + selectionWidth;
1455
+ let leftAcc = systemColumnsWidth;
1391
1456
  for (const id of effectivePinning.left) {
1392
1457
  // Hidden pinned columns don't render, so they must contribute neither an
1393
1458
  // offset entry nor width - otherwise the next visible pinned column is
@@ -1422,7 +1487,7 @@ export function createSvGridController<
1422
1487
  // index currently hovered; `rowDropSide` says which edge the drop line paints.
1423
1488
  let rowDragActive = $state<boolean>(false);
1424
1489
  let rowDropIndex = $state<number | null>(null);
1425
- let rowDropSide = $state<"before" | "after" | null>(null);
1490
+ let rowDropSide = $state<"before" | "after" | "into" | null>(null);
1426
1491
 
1427
1492
 
1428
1493
 
@@ -1952,17 +2017,16 @@ export function createSvGridController<
1952
2017
  // so consumers don't see spurious callbacks during re-renders.
1953
2018
  let lastCellRangeSerialized = "";
1954
2019
  $effect(() => {
1955
- const a = selectionRange.anchor;
1956
- const f = selectionRange.focus;
1957
- const ranges: Array<[number, number, number, number]> =
1958
- a && f
1959
- ? [[
1960
- Math.min(a.rowIndex, f.rowIndex),
1961
- Math.min(a.colIndex, f.colIndex),
1962
- Math.max(a.rowIndex, f.rowIndex),
1963
- Math.max(a.colIndex, f.colIndex),
1964
- ]]
1965
- : [];
2020
+ // Every rectangle, the committed ones a Ctrl+click added and the
2021
+ // active one last, the order getSelectionRects gives: a consumer's
2022
+ // status bar and shading cover the whole selection, not only its last
2023
+ // range. Depend on selectionRanges so a change to the committed set
2024
+ // re-runs this too.
2025
+ void selectionRanges;
2026
+ const ranges: Array<[number, number, number, number]> = getSelectionRects().map(
2027
+ (r: { minRow: number; minCol: number; maxRow: number; maxCol: number }) =>
2028
+ [r.minRow, r.minCol, r.maxRow, r.maxCol] as [number, number, number, number],
2029
+ );
1966
2030
  const serialized = JSON.stringify(ranges);
1967
2031
  if (serialized === lastCellRangeSerialized) return;
1968
2032
  lastCellRangeSerialized = serialized;
@@ -3063,11 +3127,21 @@ export function createSvGridController<
3063
3127
  return total;
3064
3128
  });
3065
3129
  /** The declared or dragged height of one row; measured under auto height. */
3130
+ /** A detail row's declared height, or null when the row is not one (or none is declared). */
3131
+ function detailRowHeightPx(i: number): number | null {
3132
+ const dh = props.detailRowHeight;
3133
+ if (dh == null || !props.isDetailRow) return null;
3134
+ const row = allRows[i];
3135
+ if (!row || !props.isDetailRow(row.original as TData, i)) return null;
3136
+ return typeof dh === "function" ? dh(row.original as TData) : dh;
3137
+ }
3066
3138
  function rowSizePxOf(i: number): number {
3067
3139
  if (isRowCollapsed(i)) return 0;
3068
3140
  if (autoRowHeightOn) return measuredRowHeights.get(i) ?? autoRowHeightFallback;
3069
3141
  const own = rowResizeHeightPx(i);
3070
3142
  if (own != null) return own;
3143
+ const detail = detailRowHeightPx(i);
3144
+ if (detail != null) return detail;
3071
3145
  const rh = props.rowHeight;
3072
3146
  return typeof rh === "function" ? rh(i) : (rh ?? 30);
3073
3147
  }
@@ -3077,10 +3151,94 @@ export function createSvGridController<
3077
3151
  for (let k = 0; k < i; k += 1) top += rowSizePxOf(k);
3078
3152
  return top;
3079
3153
  }
3154
+ // --- Sticky group rows --------------------------------------------------
3155
+ // The group a row belongs to, kept under the header while its rows scroll
3156
+ // past. The ancestors of the first row under the header are the nearest
3157
+ // group rows above it at each shallower level; under virtualization they
3158
+ // are usually outside the rendered window, so the band renders a copy of
3159
+ // each (its height taken off the top spacer, like the frozen band), and
3160
+ // without virtualization the rows themselves are made sticky.
3161
+ type StickyGroupRow = { row: Row<TData>; rowIndex: number; top: number; size: number };
3162
+ const NO_STICKY: StickyGroupRow[] = [];
3163
+ /** Whether a row is a group and how deep it sits, for any of the three tree sources. */
3164
+ function rowGroupLevel(row: Row<TData>): { group: boolean; level: number } {
3165
+ const sg = props.serverGroup;
3166
+ if (sg) {
3167
+ const data = row.original as TData;
3168
+ return { group: sg.isGroup(data), level: sg.level(data) };
3169
+ }
3170
+ // Client grouping and tree data: an expandable row is the group (a tree
3171
+ // row is a data row too, which is why isGroupRow() excludes it, but for
3172
+ // the band it is the parent its children scroll under).
3173
+ if (typeof row.getCanExpand === "function" && row.getCanExpand()) return { group: true, level: row.depth ?? 0 };
3174
+ return { group: false, level: row.depth ?? 0 };
3175
+ }
3176
+ /** The first row whose bottom edge is under the header, in row-index space. */
3177
+ const firstRowUnderHeader = $derived.by(function firstRowUnderHeader_d() {
3178
+ if (allRows.length === 0) return -1;
3179
+ if (rowVirtualizationEnabled) {
3180
+ virtualizer.version;
3181
+ const offset = virtualizer.getState().scrollOffset;
3182
+ const hit = virtualRows.find((it) => it.end > offset);
3183
+ return hit ? hit.index : -1;
3184
+ }
3185
+ rowResizeVersion;
3186
+ autoRowHeightVersion;
3187
+ const top = scrollMetrics.scrollTop;
3188
+ let acc = 0;
3189
+ let i = 0;
3190
+ while (i < allRows.length - 1 && acc + rowSizePxOf(i) <= top) {
3191
+ acc += rowSizePxOf(i);
3192
+ i += 1;
3193
+ }
3194
+ return i;
3195
+ });
3196
+ const stickyGroupRows = $derived.by(function stickyGroupRows_d(): StickyGroupRow[] {
3197
+ if (!props.stickyGroupRows) return NO_STICKY;
3198
+ const first = firstRowUnderHeader;
3199
+ if (first < 0) return NO_STICKY;
3200
+ const head = allRows[first];
3201
+ if (!head) return NO_STICKY;
3202
+ // A group row at level L, or a leaf at level L, has its ancestors at
3203
+ // levels L-1 .. 0, each the nearest group row above at that level.
3204
+ let want = rowGroupLevel(head).level - 1;
3205
+ if (want < 0) return NO_STICKY;
3206
+ const found: Array<{ row: Row<TData>; rowIndex: number }> = [];
3207
+ for (let i = first - 1; i >= frozenRowCount && want >= 0; i -= 1) {
3208
+ const row = allRows[i]!;
3209
+ const info = rowGroupLevel(row);
3210
+ if (info.group && info.level === want) {
3211
+ found.push({ row, rowIndex: i });
3212
+ want -= 1;
3213
+ }
3214
+ }
3215
+ if (found.length === 0) return NO_STICKY;
3216
+ found.reverse();
3217
+ rowResizeVersion;
3218
+ autoRowHeightVersion;
3219
+ let top = headerHeight + frozenBandHeight;
3220
+ return found.map((f) => {
3221
+ const at = top;
3222
+ const size = rowSizePxOf(f.rowIndex);
3223
+ top += size;
3224
+ return { ...f, top: at, size };
3225
+ });
3226
+ });
3227
+ /** The band's height under virtualization, taken off the top spacer. */
3228
+ const stickyBandHeight = $derived(
3229
+ rowVirtualizationEnabled ? stickyGroupRows.reduce((sum, s) => sum + s.size, 0) : 0,
3230
+ );
3231
+ /** Without virtualization the real row sticks: its top, or undefined. */
3232
+ function stickyTopOfRow(rowIndex: number): number | undefined {
3233
+ if (rowVirtualizationEnabled || stickyGroupRows.length === 0) return undefined;
3234
+ return stickyGroupRows.find((s) => s.rowIndex === rowIndex)?.top;
3235
+ }
3236
+
3080
3237
  // Spacer heights in DOM space. With scaling inert these equal the original
3081
- // virtualRowStart / virtualRowBottomSpacer. The frozen band is in the flow
3082
- // before the spacer whatever the window shows, so it comes off the spacer.
3083
- const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment - frozenBandHeight, 0));
3238
+ // virtualRowStart / virtualRowBottomSpacer. The frozen band and the sticky
3239
+ // group band are in the flow before the spacer whatever the window shows,
3240
+ // so they come off the spacer.
3241
+ const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment - frozenBandHeight - stickyBandHeight, 0));
3084
3242
  const rowBottomSpacer = $derived(
3085
3243
  Math.max(rowDomTotalSize - (virtualRowEnd - rowOffsetAdjustment), 0),
3086
3244
  );
@@ -3165,10 +3323,7 @@ export function createSvGridController<
3165
3323
  * `fittedColumnWidths`, so the overflow decision settles in the same
3166
3324
  * render where fit-scaling lands - no race, no scrollbar flash. */
3167
3325
  const hasHorizontalOverflow = $derived.by(function hasHorizontalOverflow_d() {
3168
- const fixedCols =
3169
- (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
3170
- (showRowSelectionEffective ? selectionColumnWidth : 0);
3171
- let total = fixedCols;
3326
+ let total = systemColumnsWidth;
3172
3327
  for (const column of allColumns) total += getColumnWidth(column.id);
3173
3328
  // +1 to tolerate sub-pixel rounding residue from `fitColumns`.
3174
3329
  return total > viewportWidth + 1;
@@ -3620,9 +3775,13 @@ export function createSvGridController<
3620
3775
  : typeof rh === "function"
3621
3776
  ? rh
3622
3777
  : (rh ?? 30);
3623
- const estimateSize = rowResizeHeights.size > 0 || collapsedRows.size > 0
3778
+ // Detail rows have their own height; only when the props say so does
3779
+ // the per-index lookup replace the uniform fast path.
3780
+ const detailOn = props.detailRowHeight != null && !!props.isDetailRow;
3781
+ const estimateSize = rowResizeHeights.size > 0 || collapsedRows.size > 0 || detailOn
3624
3782
  ? (index: number) =>
3625
3783
  rowResizeHeightPx(index) ??
3784
+ detailRowHeightPx(index) ??
3626
3785
  (typeof base === "function" ? base(index) : base)
3627
3786
  : base;
3628
3787
  virtualizer.setOptions({
@@ -3761,6 +3920,14 @@ export function createSvGridController<
3761
3920
  // the only thing that can answer honestly once the data is a window
3762
3921
  // onto a server.
3763
3922
  if (props.rowSelectionModel) return props.rowSelectionModel.headerState();
3923
+ // Nothing checked is the common case and needs no walk: this derived
3924
+ // re-runs on every data change, and on a 100k-row live feed the filter
3925
+ // below was 30 ms per tick spent to conclude "none".
3926
+ let anyChecked = false;
3927
+ for (const id in rowSelectionState) {
3928
+ if (rowSelectionState[id]) { anyChecked = true; break; }
3929
+ }
3930
+ if (!anyChecked) return "none";
3764
3931
  const selectable = allRows.filter(
3765
3932
  (row) => !isGroupRow(row) && !placeholderStateOf(row),
3766
3933
  );
@@ -3810,8 +3977,6 @@ export function createSvGridController<
3810
3977
  // widths stay in the fit distribution and leave a blank gap at the edge.
3811
3978
  const cols = grid.getAllColumns().filter((c) => !hiddenColumns[c.id] && !hiddenByGroupCollapse[c.id] && !collapsedColumns[c.id]);
3812
3979
  if (!cols.length) return null;
3813
- const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
3814
- const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
3815
3980
  // Reserve the custom vertical scrollbar's width when it's visible. It
3816
3981
  // overlays the right 16px of the viewport (absolute, z-index 40) and
3817
3982
  // does NOT shrink clientWidth, so without this the last fitted column
@@ -3820,8 +3985,7 @@ export function createSvGridController<
3820
3985
  const scrollbarWidth = hasVerticalOverflow ? 16 : 0;
3821
3986
  const target =
3822
3987
  (scrollContainer?.clientWidth ?? 0) -
3823
- rowNumberWidth -
3824
- selectionWidth -
3988
+ systemColumnsWidth -
3825
3989
  scrollbarWidth;
3826
3990
  if (target <= 0) return null;
3827
3991
 
@@ -4319,6 +4483,8 @@ export function createSvGridController<
4319
4483
  set historyPtr(v) { historyPtr = v as never; },
4320
4484
  get historyGroupId() { return historyGroupId; },
4321
4485
  set historyGroupId(v) { historyGroupId = v as never; },
4486
+ get historyTag() { return historyTag; },
4487
+ set historyTag(v) { historyTag = v; },
4322
4488
  get historyVersion() { return historyVersion; },
4323
4489
  set historyVersion(v) { historyVersion = v as never; },
4324
4490
  get tooltip() { return tooltip; },
@@ -4355,6 +4521,11 @@ export function createSvGridController<
4355
4521
  get gridStateVersion() { return gridStateVersion; },
4356
4522
  set gridStateVersion(v) { gridStateVersion = v as never; },
4357
4523
  get selectionColumnWidth() { return selectionColumnWidth; },
4524
+ get detailToggleColumnWidth() { return detailToggleColumnWidth; },
4525
+ get detailToggleColumnLeft() { return detailToggleColumnLeft; },
4526
+ get showDetailToggleEffective() { return showDetailToggleEffective; },
4527
+ get systemColumnsWidth() { return systemColumnsWidth; },
4528
+ get detailToggleOf() { return detailToggleOf; },
4358
4529
  get rowNumberColumnWidth() { return rowNumberColumnWidth; },
4359
4530
  get showRowNumbersEffective() { return showRowNumbersEffective; },
4360
4531
  get filterOperatorOptions() { return filterOperatorOptions; },
@@ -4772,6 +4943,8 @@ export function createSvGridController<
4772
4943
  get frozenRowList() { return frozenRowList; },
4773
4944
  get frozenBandHeight() { return frozenBandHeight; },
4774
4945
  get frozenRowTop() { return frozenRowTop; },
4946
+ get stickyGroupRows() { return stickyGroupRows; },
4947
+ get stickyTopOfRow() { return stickyTopOfRow; },
4775
4948
  /** Record the height the user dragged a row to. */
4776
4949
  get setRowResizeHeight() { return setRowResizeHeight; },
4777
4950
  get isRowCollapsed() { return isRowCollapsed; },
@@ -4784,6 +4957,12 @@ export function createSvGridController<
4784
4957
  rowResizeVersion;
4785
4958
  return rowResizeHeightPx;
4786
4959
  },
4960
+ /** The height row i is drawn at: dragged, measured, detail, declared or default. */
4961
+ get rowSizePxOf() {
4962
+ rowResizeVersion;
4963
+ autoRowHeightVersion;
4964
+ return rowSizePxOf;
4965
+ },
4787
4966
  get virtualRowTotalSize() { return virtualRowTotalSize; },
4788
4967
  get virtualRowStart() { return virtualRowStart; },
4789
4968
  get virtualRowEnd() { return virtualRowEnd; },
package/src/SvGrid.css CHANGED
@@ -535,6 +535,10 @@
535
535
  .sv-grid-placeholder-retry:hover {
536
536
  background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent);
537
537
  }
538
+ .sv-grid-placeholder-retry:focus-visible {
539
+ outline: 2px solid var(--sg-accent, #2563eb);
540
+ outline-offset: 2px;
541
+ }
538
542
 
539
543
  /* Pinned columns read as "frozen" via three layered cues:
540
544
  1. A distinct fill (slightly stronger than the header) so the
@@ -813,6 +817,12 @@
813
817
  .sv-grid-row-drop-after > td {
814
818
  box-shadow: inset 0 -2px 0 0 var(--sg-accent, #6366f1);
815
819
  }
820
+ /* Landing inside a group or tree row (the row becomes its child): the
821
+ * whole row is the target, so it takes a tint and a ring rather than a line. */
822
+ .sv-grid-row-drop-into > td {
823
+ box-shadow: inset 0 2px 0 0 var(--sg-accent, #6366f1), inset 0 -2px 0 0 var(--sg-accent, #6366f1);
824
+ background: color-mix(in srgb, var(--sg-accent, #6366f1) 10%, transparent);
825
+ }
816
826
 
817
827
  /* Pinned rows (top / bottom). Each cell sticks individually so the
818
828
  * row tracks scroll on every browser. Top rows stick below the sticky
@@ -837,6 +847,27 @@
837
847
  .sv-grid-pinned-row-bottom[data-pinned-index="1"] td { bottom: var(--sg-pinned-row-h, 32px); }
838
848
  .sv-grid-pinned-row-bottom[data-pinned-index="2"] td { bottom: calc(var(--sg-pinned-row-h, 32px) * 2); }
839
849
  .sv-grid-pinned-row-bottom[data-pinned-index="3"] td { bottom: calc(var(--sg-pinned-row-h, 32px) * 3); }
850
+ /* While the selection bar is up the scroller carries bottom padding (see
851
+ --sg-selbar-reserve), and a sticky bottom edge is inset by that padding:
852
+ the lowest pinned row then holds 68px above the viewport's edge, with the
853
+ body rows scrolling through the strip beneath it, in and out from behind
854
+ the bar. That strip is meant to be the bar's own ground, so the lowest
855
+ pinned row paints it: a shadow in the body colour, spread by half the
856
+ reserve and pushed down by the same, fills the strip under each cell; the
857
+ clip cuts the spread's sideways spill at the cell's own edges so a later
858
+ cell's shadow never paints over its neighbour. The cells sit at z-index 4,
859
+ above the rows and under the floating bar. */
860
+ .sv-grid-root[data-selbar$='bottom'] .sv-grid-pinned-row-bottom[data-pinned-index="0"] td {
861
+ box-shadow: 0 calc(var(--sg-selbar-reserve) / 2) 0 calc(var(--sg-selbar-reserve) / 2) var(--sg-bg, #fff);
862
+ clip-path: inset(0 0 calc(-1 * var(--sg-selbar-reserve)) 0);
863
+ }
864
+ /* The same strip above a sticky header when the bar sits at the top: the
865
+ header holds under the reserve and rows scrolled up showed through above
866
+ it. The first header row's cells paint the strip the same way. */
867
+ .sv-grid-root[data-selbar$='top'] .sv-grid-head > tr:first-child > th {
868
+ box-shadow: 0 calc(-1 * var(--sg-selbar-reserve) / 2) 0 calc(var(--sg-selbar-reserve) / 2) var(--sg-bg, #fff);
869
+ clip-path: inset(calc(-1 * var(--sg-selbar-reserve)) 0 0 0);
870
+ }
840
871
  /* Sticky-left columns keep their left position; just override their
841
872
  * z so they sit above non-pinned pinned-row cells. */
842
873
  .sv-grid-pinned-row td[data-pinned] { z-index: 5; }
@@ -1018,13 +1049,58 @@
1018
1049
  z-index: 4;
1019
1050
  }
1020
1051
 
1021
- /* Row-number column: sticks to the left edge of the scroll viewport. */
1052
+ /* Detail-toggle column (`showDetailToggle`): a row-header gutter of chevrons
1053
+ that open and close detail rows. Sticky beside the row numbers and the
1054
+ selection column, on the header band's colour so it reads as chrome, not
1055
+ data; no menu, no resize handle, outside the active cell. */
1056
+ .sv-grid-detail-toggle-column,
1057
+ .sv-grid-detail-toggle-cell {
1058
+ text-align: center;
1059
+ padding: 0;
1060
+ position: sticky;
1061
+ z-index: 4;
1062
+ background: var(--sg-header-bg, #f5f7fb);
1063
+ vertical-align: middle;
1064
+ }
1065
+ .sv-grid-detail-toggle {
1066
+ display: inline-flex;
1067
+ align-items: center;
1068
+ justify-content: center;
1069
+ width: 24px;
1070
+ height: 24px;
1071
+ padding: 0;
1072
+ border: 0;
1073
+ border-radius: 5px;
1074
+ background: transparent;
1075
+ color: var(--sg-muted, #64748b);
1076
+ cursor: pointer;
1077
+ vertical-align: middle;
1078
+ }
1079
+ .sv-grid-detail-toggle:hover {
1080
+ background: color-mix(in srgb, var(--sg-accent, #2563eb) 12%, transparent);
1081
+ color: var(--sg-accent, #2563eb);
1082
+ }
1083
+ .sv-grid-detail-toggle:focus-visible {
1084
+ outline: 2px solid var(--sg-accent, #2563eb);
1085
+ outline-offset: 1px;
1086
+ }
1087
+ .sv-grid-detail-toggle > .sv-grid-icon {
1088
+ width: 12px;
1089
+ height: 12px;
1090
+ transition: transform 0.15s ease;
1091
+ }
1092
+ .sv-grid-detail-toggle[aria-expanded="true"] > .sv-grid-icon {
1093
+ transform: rotate(90deg);
1094
+ }
1095
+
1096
+ /* Row-number column: sticks to the inline-start edge of the scroll viewport,
1097
+ which is the right edge under `dir="rtl"`. */
1022
1098
  .sv-grid-row-number-column,
1023
1099
  .sv-grid-row-number-cell {
1024
1100
  text-align: right;
1025
1101
  padding-inline: 8px;
1026
1102
  position: sticky;
1027
- left: 0;
1103
+ inset-inline-start: 0;
1028
1104
  z-index: 4;
1029
1105
  /* Match the column-header band so the row-number gutter and the column
1030
1106
  headers form one continuous header chrome. */
@@ -1311,9 +1387,6 @@ select.sv-grid-fr-editor {
1311
1387
  .sv-grid-cell-active,
1312
1388
  .sv-grid-cell-editing {
1313
1389
  position: relative;
1314
- /* High enough to sit above pinned-column stacking contexts (z=4) so the
1315
- list/chips popover overlays adjacent cells instead of being clipped. */
1316
- z-index: 20;
1317
1390
  /* `box-shadow: inset` is bound to the cell's own box, so the ring
1318
1391
  clips cleanly with the cell when it's partially scrolled out of
1319
1392
  view (and shows up consistently at the right edge of a virtualized
@@ -1323,10 +1396,33 @@ select.sv-grid-fr-editor {
1323
1396
  inconsistent at scroll boundaries. */
1324
1397
  box-shadow: inset 0 0 0 2px var(--sg-accent, #0b63f3);
1325
1398
  outline: none;
1326
- /* Let list/chips popouts extend past the cell box (default cells have
1327
- overflow: hidden so the chips picker would otherwise be clipped). */
1399
+ }
1400
+ /* Only the EDITING cell escapes its own box and outranks the pinned-column
1401
+ stacking contexts (z=4), so its list/chips popout overlays the neighbours
1402
+ instead of being clipped. The merely-ACTIVE cell used to get the same
1403
+ treatment, and on a grid scrolled sideways that made it paint straight over
1404
+ the sticky selection / row-number column: the checkbox disappeared and the
1405
+ cell's text - unclipped, because overflow was visible - spilled across it.
1406
+ A grid seeds its active cell at (0,0), so this hit the FIRST row of any
1407
+ horizontally scrolled grid with `showRowSelection`, untouched by the user. */
1408
+ .sv-grid-cell-editing {
1409
+ z-index: 20;
1328
1410
  overflow: visible;
1329
1411
  }
1412
+ /* Above ordinary cells so the ring is not overdrawn by a neighbour, below the
1413
+ sticky columns (z=4) so those keep covering it as they should. */
1414
+ .sv-grid-cell-active:not(.sv-grid-cell-editing) {
1415
+ z-index: 3;
1416
+ }
1417
+ /* The active cell a grid mounts with is seeded at (0,0), not chosen: no
1418
+ ring until a click, an arrow key or the api activates a cell (the same
1419
+ gate the fill handle has always had). Once activated it stays through a
1420
+ blur, so a toolbar acting on "the focused row" keeps its cue. A page with
1421
+ six grids on it, or a detail grid inside each open row, used to open
1422
+ with an accent ring in every one of them. */
1423
+ .sv-grid-active-seeded .sv-grid-cell-active {
1424
+ box-shadow: none;
1425
+ }
1330
1426
 
1331
1427
  /* ---- The editing cell's border -------------------------------------------
1332
1428
  One ring for every editor, drawn by the CELL rather than by each editor.
@@ -1625,6 +1721,40 @@ select.sv-grid-fr-editor {
1625
1721
  border-bottom: 1px solid var(--sg-frozen-edge, var(--sg-border, #94a3b8));
1626
1722
  }
1627
1723
 
1724
+ /* A detail row with a declared height (detailRowHeight): the row keeps the
1725
+ height the virtualizer counted on - the auto-height rule above must not
1726
+ win - and the panel's body, a block inside the cell, takes that height
1727
+ and scrolls what does not fit. (A table cell's own height is only a
1728
+ minimum, so the height goes on the block.) */
1729
+ .sv-grid-row.sv-grid-detail-row.sv-grid-detail-row-sized {
1730
+ height: var(--sv-detail-h) !important;
1731
+ }
1732
+ .sv-grid-row.sv-grid-detail-row-sized > .sv-grid-detail-cell {
1733
+ vertical-align: top;
1734
+ box-sizing: border-box;
1735
+ height: var(--sv-detail-h);
1736
+ }
1737
+ .sv-grid-detail-body {
1738
+ box-sizing: border-box;
1739
+ height: 100%;
1740
+ overflow: auto;
1741
+ }
1742
+
1743
+ /* Sticky group rows: the group a row belongs to, held under the header
1744
+ (and under the frozen band) while its rows scroll past. Same layer as
1745
+ the frozen band; the last one carries the band's edge. */
1746
+ .sv-grid-row.sv-grid-row-sticky-group {
1747
+ position: sticky;
1748
+ z-index: 31;
1749
+ background: var(--sg-bg, #fff);
1750
+ }
1751
+ .sv-grid-row.sv-grid-row-sticky-group > .sv-grid-cell {
1752
+ background: var(--sg-bg, #fff);
1753
+ }
1754
+ .sv-grid-row.sv-grid-row-sticky-group-last > .sv-grid-cell {
1755
+ border-bottom: 1px solid var(--sg-frozen-edge, var(--sg-border, #94a3b8));
1756
+ }
1757
+
1628
1758
  /* The multiline text editor sizes itself by its lines and keeps the
1629
1759
  input's single-line look until a break is typed. */
1630
1760
  .sv-grid-cell-editor-multiline {
@@ -3082,6 +3212,12 @@ select.sv-grid-fr-editor {
3082
3212
  .sv-grid-row-number-cell {
3083
3213
  border-right-color: var(--sg-header-bg, #f1f5f9);
3084
3214
  }
3215
+ /* The detail-toggle gutter fuses with the row numbers the same way; only
3216
+ its own right edge keeps a line, between the chrome and the data. */
3217
+ .sv-grid-row-number-cell:has(+ .sv-grid-detail-toggle-cell),
3218
+ .sv-grid-row-number-column:has(+ .sv-grid-detail-toggle-column) {
3219
+ border-right-color: var(--sg-header-bg, #f1f5f9);
3220
+ }
3085
3221
 
3086
3222
  /* Group row label colour. */
3087
3223
  .sv-grid-group-row > .sv-grid-cell {