@svgrid/grid 3.0.5 → 3.0.6

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 (148) hide show
  1. package/README.md +28 -7
  2. package/dist/GridMenus.svelte +34 -4
  3. package/dist/SvGrid.controller.svelte.d.ts +21 -3
  4. package/dist/SvGrid.controller.svelte.js +205 -50
  5. package/dist/SvGrid.css +127 -2
  6. package/dist/SvGrid.svelte +223 -38
  7. package/dist/SvGrid.types.d.ts +77 -4
  8. package/dist/SvGridDropdown.svelte +20 -0
  9. package/dist/SvGridDropdown.svelte.d.ts +12 -0
  10. package/dist/SvModal.svelte +22 -0
  11. package/dist/SvModal.svelte.d.ts +7 -0
  12. package/dist/a11y/dismissable.d.ts +10 -0
  13. package/dist/a11y/dismissable.js +24 -2
  14. package/dist/build-api.js +81 -31
  15. package/dist/cdn/{GridMenus-CID62rlI.js → GridMenus-C-NtjlrJ.js} +150 -141
  16. package/dist/cdn/GridMenus-TssHej3q.js +644 -0
  17. package/dist/cdn/{SvChartMenu-FBSMINA6.js → SvChartMenu-BFSTpvB-.js} +1 -1
  18. package/dist/cdn/{SvChartMenu-Bl6PBbkT.js → SvChartMenu-BZ1Iwv0y.js} +1 -1
  19. package/dist/cdn/{SvDateTimePicker-DQwt4UAs.js → SvDateTimePicker-D9u_cOf8.js} +1 -1
  20. package/dist/cdn/{SvDateTimePicker-vNU6bZ-q.js → SvDateTimePicker-s_6IUpI7.js} +1 -1
  21. package/dist/cdn/{SvGridCellEditor-B1p-vCK5.js → SvGridCellEditor-BlyCSp0q.js} +1 -1
  22. package/dist/cdn/{SvGridCellEditor-D_0q4xAu.js → SvGridCellEditor-ClRRA9lW.js} +1 -1
  23. package/dist/cdn/{SvGridChart-DzLkSwCH.js → SvGridChart-B4EV8U6t.js} +4 -4
  24. package/dist/cdn/{SvGridChart-C3EWAZaM.js → SvGridChart-DsmwTyZF.js} +4 -4
  25. package/dist/cdn/{SvGridChartBuilder-CfII62sZ.js → SvGridChartBuilder-cHloHobN.js} +4 -4
  26. package/dist/cdn/{SvGridChartBuilder-BE2T1ykB.js → SvGridChartBuilder-vOl7C0Za.js} +4 -4
  27. package/dist/cdn/{SvGridChartPanel-xf4sWVTo.js → SvGridChartPanel-AcqF-Ll5.js} +5 -5
  28. package/dist/cdn/{SvGridChartPanel-CCX5_Wgd.js → SvGridChartPanel-biMtSBPA.js} +5 -5
  29. package/dist/cdn/{SvGridChartView-DYwabQWj.js → SvGridChartView-Dx_GmZuL.js} +2 -2
  30. package/dist/cdn/{SvGridChartView-CfuXmY5I.js → SvGridChartView-I3rDHXAq.js} +2 -2
  31. package/dist/cdn/{SvGridDropdown-D0VdjeR8.js → SvGridDropdown-BWeM3lMe.js} +46 -46
  32. package/dist/cdn/{SvGridDropdown-D13MtJ2j.js → SvGridDropdown-CJ6QqTxu.js} +67 -67
  33. package/dist/cdn/{SvModal-CIZWCcad.js → SvModal-ClxGmpAc.js} +187 -186
  34. package/dist/cdn/{SvModal-BAE-pjZX.js → SvModal-DhB_VMFR.js} +148 -147
  35. package/dist/cdn/{cell-formatting-C2Hf5gqd.js → cell-formatting-DVVKOSI9.js} +1 -1
  36. package/dist/cdn/{chart-Dz7SqMXH.js → chart-DEZxwC0i.js} +1 -1
  37. package/dist/cdn/{chart-panel-messages-CUbf2R4i.js → chart-panel-messages-D12XcTZJ.js} +1 -1
  38. package/dist/cdn/{chart-panel-messages-CmNrMdsr.js → chart-panel-messages-DguS7gC5.js} +1 -1
  39. package/dist/cdn/{chart-summary-BJW_tg_X.js → chart-summary-CMAXBGY-.js} +1 -1
  40. package/dist/cdn/{chart-trend-CaN9mDEV.js → chart-trend-BkEp61Gj.js} +1 -1
  41. package/dist/cdn/{dismissable-DAHetSNk.js → dismissable-Brc4EZU1.js} +5 -1
  42. package/dist/cdn/{export-format-CUDLy2yn.js → export-format-YzdwBww2.js} +1 -1
  43. package/dist/cdn/{row-resize-niQCp040.js → row-resize-DtOS1KG6.js} +41 -32
  44. package/dist/cdn/{server-block-cache-CGoWz-87.js → server-block-cache-DIqb3VZn.js} +4 -0
  45. package/dist/cdn/{src-jcFMkBnN.js → src-CgoeSM8k.js} +4697 -4131
  46. package/dist/cdn/{src-Bklofsad.js → src-DfwxB1TH.js} +4727 -4161
  47. package/dist/cdn/svgrid.js +17 -17
  48. package/dist/cdn/svgrid.svelte-external.js +17 -17
  49. package/dist/cdn/{validate-C7Rn_Bns.js → validate-AZoD8BoR.js} +6 -2
  50. package/dist/cell-formatting.js +18 -1
  51. package/dist/cell-render.js +25 -6
  52. package/dist/clipboard.d.ts +2 -2
  53. package/dist/clipboard.js +72 -23
  54. package/dist/columns.js +7 -2
  55. package/dist/conditional-formatting.d.ts +2 -0
  56. package/dist/conditional-formatting.js +2 -0
  57. package/dist/core.d.ts +22 -6
  58. package/dist/core.js +365 -25
  59. package/dist/editing.js +24 -0
  60. package/dist/grid-messages.d.ts +4 -0
  61. package/dist/grid-messages.js +4 -0
  62. package/dist/headless.d.ts +5 -0
  63. package/dist/headless.js +12 -0
  64. package/dist/history.d.ts +21 -0
  65. package/dist/history.js +34 -4
  66. package/dist/index.d.ts +1 -1
  67. package/dist/keyboard-handlers.js +24 -1
  68. package/dist/keyboard.js +4 -0
  69. package/dist/row-drag-touch.d.ts +1 -1
  70. package/dist/row-drag.js +35 -1
  71. package/dist/row-model.d.ts +6 -0
  72. package/dist/row-resize.js +22 -1
  73. package/dist/selection.js +1 -1
  74. package/dist/server-block-cache.d.ts +5 -0
  75. package/dist/server-block-cache.js +8 -0
  76. package/dist/server-data-source.d.ts +35 -3
  77. package/dist/server-data-source.js +11 -2
  78. package/dist/server.d.ts +1 -1
  79. package/dist/sparkline.js +7 -5
  80. package/dist/svgrid-wrapper.types.d.ts +33 -0
  81. package/dist/validate.js +9 -1
  82. package/package.json +6 -1
  83. package/src/GridMenus.svelte +34 -4
  84. package/src/SvGrid.controller.svelte.ts +206 -53
  85. package/src/SvGrid.css +127 -2
  86. package/src/SvGrid.svelte +223 -38
  87. package/src/SvGrid.types.ts +77 -4
  88. package/src/SvGridDropdown.svelte +20 -0
  89. package/src/SvModal.svelte +22 -0
  90. package/src/SvModal.test.ts +30 -1
  91. package/src/a11y/dismissable.test.ts +10 -1
  92. package/src/a11y/dismissable.ts +22 -2
  93. package/src/build-api.ts +85 -27
  94. package/src/cell-formatting.test.ts +17 -0
  95. package/src/cell-formatting.ts +18 -1
  96. package/src/cell-render.test.ts +24 -2
  97. package/src/cell-render.ts +24 -8
  98. package/src/clipboard.test.ts +107 -0
  99. package/src/clipboard.ts +62 -23
  100. package/src/columns.test.ts +4 -4
  101. package/src/columns.ts +7 -2
  102. package/src/conditional-formatting.test.ts +8 -0
  103. package/src/conditional-formatting.ts +3 -0
  104. package/src/core.tick-repair.test.ts +287 -0
  105. package/src/core.ts +374 -26
  106. package/src/editing.test.ts +52 -0
  107. package/src/editing.ts +25 -0
  108. package/src/grid-messages.test.ts +7 -0
  109. package/src/grid-messages.ts +10 -0
  110. package/src/headless.ts +24 -0
  111. package/src/history.test.ts +49 -1
  112. package/src/history.ts +48 -2
  113. package/src/index.ts +2 -0
  114. package/src/keyboard-handlers.coverage.test.ts +65 -0
  115. package/src/keyboard-handlers.ts +26 -1
  116. package/src/keyboard.ts +3 -0
  117. package/src/row-drag-touch.ts +2 -2
  118. package/src/row-drag.test.ts +51 -0
  119. package/src/row-drag.ts +35 -2
  120. package/src/row-model.ts +6 -0
  121. package/src/row-resize.ts +24 -1
  122. package/src/selection.ts +1 -1
  123. package/src/server-block-cache.test.ts +27 -0
  124. package/src/server-block-cache.ts +13 -0
  125. package/src/server-data-source.infinite.test.ts +62 -0
  126. package/src/server-data-source.test.ts +19 -0
  127. package/src/server-data-source.ts +48 -4
  128. package/src/server.ts +2 -0
  129. package/src/sparkline.test.ts +7 -0
  130. package/src/sparkline.ts +6 -4
  131. package/src/svgrid-wrapper.types.ts +32 -0
  132. package/src/svgrid.api.test.ts +21 -0
  133. package/src/svgrid.charting.test.ts +12 -4
  134. package/src/svgrid.context-menu.test.ts +12 -0
  135. package/src/svgrid.detail-rows.svelte.test.ts +187 -0
  136. package/src/svgrid.filter-menu-scroll.test.ts +9 -0
  137. package/src/svgrid.gantt-seam.test.ts +1 -1
  138. package/src/svgrid.live-update-paths.svelte.test.ts +184 -0
  139. package/src/svgrid.menu-scroll-close.test.ts +10 -0
  140. package/src/svgrid.row-model-seam.svelte.test.ts +16 -0
  141. package/src/svgrid.row-pinning.test.ts +15 -1
  142. package/src/svgrid.selection-bar-seam.test.ts +1 -1
  143. package/src/svgrid.sticky-groups.svelte.test.ts +145 -0
  144. package/src/svgrid.upsell-license.test.ts +1 -1
  145. package/src/test-setup.ts +12 -0
  146. package/src/transaction.test.ts +58 -0
  147. package/src/validate.ts +7 -1
  148. 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'),
@@ -890,36 +925,42 @@ export function createSvGridController<
890
925
  // svelte-ignore state_referenced_locally
891
926
  const treeDataConfig = props.treeData;
892
927
 
928
+ // Pagination is intentionally NOT in the grid's row-model pipeline. The
929
+ // wrapper applies its own filters (filterMenuValues, globalFilter,
930
+ // valueFilters) on top of `grid.getRowModel().rows`. If pagination ran
931
+ // first, those filters would only see the visible page. Instead the wrapper
932
+ // paginates last - see `allRows` below.
933
+ //
934
+ // Built ONCE. Both inputs are captured at mount, and the stages keep state
935
+ // between runs: the sorted and filtered stages hold their last output so a
936
+ // data tick can repair it instead of recomputing (core.ts, `replacedRowsOf`).
937
+ // This used to be a getter that built fresh stages on every read, which
938
+ // threw that cache away on each run - a live feed re-sorted 100k rows on
939
+ // every tick and the row-model memo's `cachedPipeline` never matched.
940
+ const rowModels = {
941
+ coreRowModel: createCoreRowModel<TData>(),
942
+ filteredRowModel: createFilteredRowModel<TData>(),
943
+ // External-sort mode: pass the rows through untouched so the consumer
944
+ // controls ordering (e.g. tree data that must preserve hierarchy).
945
+ sortedRowModel: externalSortEnabled
946
+ ? passthroughSortedRowModel
947
+ : createSortedRowModel<TData>(sortFns),
948
+ // Tree nesting replaces grouping when `treeData` is set - a row cannot
949
+ // be both a hierarchy node and bucketed under a group banner.
950
+ groupedRowModel: treeDataConfig
951
+ ? createTreeRowModel<TData>({
952
+ parentField: treeDataConfig.parentField,
953
+ idField: treeDataConfig.idField,
954
+ })
955
+ : createGroupedRowModel<TData>(),
956
+ expandedRowModel: createExpandedRowModel<TData>(),
957
+ };
893
958
 
894
959
  const grid = createSvGrid({
895
960
  get _features() {
896
961
  return resolveEffectiveFeatures();
897
962
  },
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
- },
963
+ _rowModels: rowModels,
923
964
  get columns() {
924
965
  return internalColumns;
925
966
  },
@@ -1384,10 +1425,8 @@ export function createSvGridController<
1384
1425
 
1385
1426
  /** Cumulative pixel offsets for left- and right-pinned columns. */
1386
1427
  const pinnedOffsets = $derived.by(function pinnedOffsets_d() {
1387
- const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
1388
- const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
1389
1428
  const left: Record<string, number> = {};
1390
- let leftAcc = rowNumberWidth + selectionWidth;
1429
+ let leftAcc = systemColumnsWidth;
1391
1430
  for (const id of effectivePinning.left) {
1392
1431
  // Hidden pinned columns don't render, so they must contribute neither an
1393
1432
  // offset entry nor width - otherwise the next visible pinned column is
@@ -1422,7 +1461,7 @@ export function createSvGridController<
1422
1461
  // index currently hovered; `rowDropSide` says which edge the drop line paints.
1423
1462
  let rowDragActive = $state<boolean>(false);
1424
1463
  let rowDropIndex = $state<number | null>(null);
1425
- let rowDropSide = $state<"before" | "after" | null>(null);
1464
+ let rowDropSide = $state<"before" | "after" | "into" | null>(null);
1426
1465
 
1427
1466
 
1428
1467
 
@@ -1952,17 +1991,16 @@ export function createSvGridController<
1952
1991
  // so consumers don't see spurious callbacks during re-renders.
1953
1992
  let lastCellRangeSerialized = "";
1954
1993
  $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
- : [];
1994
+ // Every rectangle, the committed ones a Ctrl+click added and the
1995
+ // active one last, the order getSelectionRects gives: a consumer's
1996
+ // status bar and shading cover the whole selection, not only its last
1997
+ // range. Depend on selectionRanges so a change to the committed set
1998
+ // re-runs this too.
1999
+ void selectionRanges;
2000
+ const ranges: Array<[number, number, number, number]> = getSelectionRects().map(
2001
+ (r: { minRow: number; minCol: number; maxRow: number; maxCol: number }) =>
2002
+ [r.minRow, r.minCol, r.maxRow, r.maxCol] as [number, number, number, number],
2003
+ );
1966
2004
  const serialized = JSON.stringify(ranges);
1967
2005
  if (serialized === lastCellRangeSerialized) return;
1968
2006
  lastCellRangeSerialized = serialized;
@@ -3063,11 +3101,21 @@ export function createSvGridController<
3063
3101
  return total;
3064
3102
  });
3065
3103
  /** The declared or dragged height of one row; measured under auto height. */
3104
+ /** A detail row's declared height, or null when the row is not one (or none is declared). */
3105
+ function detailRowHeightPx(i: number): number | null {
3106
+ const dh = props.detailRowHeight;
3107
+ if (dh == null || !props.isDetailRow) return null;
3108
+ const row = allRows[i];
3109
+ if (!row || !props.isDetailRow(row.original as TData, i)) return null;
3110
+ return typeof dh === "function" ? dh(row.original as TData) : dh;
3111
+ }
3066
3112
  function rowSizePxOf(i: number): number {
3067
3113
  if (isRowCollapsed(i)) return 0;
3068
3114
  if (autoRowHeightOn) return measuredRowHeights.get(i) ?? autoRowHeightFallback;
3069
3115
  const own = rowResizeHeightPx(i);
3070
3116
  if (own != null) return own;
3117
+ const detail = detailRowHeightPx(i);
3118
+ if (detail != null) return detail;
3071
3119
  const rh = props.rowHeight;
3072
3120
  return typeof rh === "function" ? rh(i) : (rh ?? 30);
3073
3121
  }
@@ -3077,10 +3125,94 @@ export function createSvGridController<
3077
3125
  for (let k = 0; k < i; k += 1) top += rowSizePxOf(k);
3078
3126
  return top;
3079
3127
  }
3128
+ // --- Sticky group rows --------------------------------------------------
3129
+ // The group a row belongs to, kept under the header while its rows scroll
3130
+ // past. The ancestors of the first row under the header are the nearest
3131
+ // group rows above it at each shallower level; under virtualization they
3132
+ // are usually outside the rendered window, so the band renders a copy of
3133
+ // each (its height taken off the top spacer, like the frozen band), and
3134
+ // without virtualization the rows themselves are made sticky.
3135
+ type StickyGroupRow = { row: Row<TData>; rowIndex: number; top: number; size: number };
3136
+ const NO_STICKY: StickyGroupRow[] = [];
3137
+ /** Whether a row is a group and how deep it sits, for any of the three tree sources. */
3138
+ function rowGroupLevel(row: Row<TData>): { group: boolean; level: number } {
3139
+ const sg = props.serverGroup;
3140
+ if (sg) {
3141
+ const data = row.original as TData;
3142
+ return { group: sg.isGroup(data), level: sg.level(data) };
3143
+ }
3144
+ // Client grouping and tree data: an expandable row is the group (a tree
3145
+ // row is a data row too, which is why isGroupRow() excludes it, but for
3146
+ // the band it is the parent its children scroll under).
3147
+ if (typeof row.getCanExpand === "function" && row.getCanExpand()) return { group: true, level: row.depth ?? 0 };
3148
+ return { group: false, level: row.depth ?? 0 };
3149
+ }
3150
+ /** The first row whose bottom edge is under the header, in row-index space. */
3151
+ const firstRowUnderHeader = $derived.by(function firstRowUnderHeader_d() {
3152
+ if (allRows.length === 0) return -1;
3153
+ if (rowVirtualizationEnabled) {
3154
+ virtualizer.version;
3155
+ const offset = virtualizer.getState().scrollOffset;
3156
+ const hit = virtualRows.find((it) => it.end > offset);
3157
+ return hit ? hit.index : -1;
3158
+ }
3159
+ rowResizeVersion;
3160
+ autoRowHeightVersion;
3161
+ const top = scrollMetrics.scrollTop;
3162
+ let acc = 0;
3163
+ let i = 0;
3164
+ while (i < allRows.length - 1 && acc + rowSizePxOf(i) <= top) {
3165
+ acc += rowSizePxOf(i);
3166
+ i += 1;
3167
+ }
3168
+ return i;
3169
+ });
3170
+ const stickyGroupRows = $derived.by(function stickyGroupRows_d(): StickyGroupRow[] {
3171
+ if (!props.stickyGroupRows) return NO_STICKY;
3172
+ const first = firstRowUnderHeader;
3173
+ if (first < 0) return NO_STICKY;
3174
+ const head = allRows[first];
3175
+ if (!head) return NO_STICKY;
3176
+ // A group row at level L, or a leaf at level L, has its ancestors at
3177
+ // levels L-1 .. 0, each the nearest group row above at that level.
3178
+ let want = rowGroupLevel(head).level - 1;
3179
+ if (want < 0) return NO_STICKY;
3180
+ const found: Array<{ row: Row<TData>; rowIndex: number }> = [];
3181
+ for (let i = first - 1; i >= frozenRowCount && want >= 0; i -= 1) {
3182
+ const row = allRows[i]!;
3183
+ const info = rowGroupLevel(row);
3184
+ if (info.group && info.level === want) {
3185
+ found.push({ row, rowIndex: i });
3186
+ want -= 1;
3187
+ }
3188
+ }
3189
+ if (found.length === 0) return NO_STICKY;
3190
+ found.reverse();
3191
+ rowResizeVersion;
3192
+ autoRowHeightVersion;
3193
+ let top = headerHeight + frozenBandHeight;
3194
+ return found.map((f) => {
3195
+ const at = top;
3196
+ const size = rowSizePxOf(f.rowIndex);
3197
+ top += size;
3198
+ return { ...f, top: at, size };
3199
+ });
3200
+ });
3201
+ /** The band's height under virtualization, taken off the top spacer. */
3202
+ const stickyBandHeight = $derived(
3203
+ rowVirtualizationEnabled ? stickyGroupRows.reduce((sum, s) => sum + s.size, 0) : 0,
3204
+ );
3205
+ /** Without virtualization the real row sticks: its top, or undefined. */
3206
+ function stickyTopOfRow(rowIndex: number): number | undefined {
3207
+ if (rowVirtualizationEnabled || stickyGroupRows.length === 0) return undefined;
3208
+ return stickyGroupRows.find((s) => s.rowIndex === rowIndex)?.top;
3209
+ }
3210
+
3080
3211
  // 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));
3212
+ // virtualRowStart / virtualRowBottomSpacer. The frozen band and the sticky
3213
+ // group band are in the flow before the spacer whatever the window shows,
3214
+ // so they come off the spacer.
3215
+ const rowTopSpacer = $derived(Math.max(virtualRowStart - rowOffsetAdjustment - frozenBandHeight - stickyBandHeight, 0));
3084
3216
  const rowBottomSpacer = $derived(
3085
3217
  Math.max(rowDomTotalSize - (virtualRowEnd - rowOffsetAdjustment), 0),
3086
3218
  );
@@ -3165,10 +3297,7 @@ export function createSvGridController<
3165
3297
  * `fittedColumnWidths`, so the overflow decision settles in the same
3166
3298
  * render where fit-scaling lands - no race, no scrollbar flash. */
3167
3299
  const hasHorizontalOverflow = $derived.by(function hasHorizontalOverflow_d() {
3168
- const fixedCols =
3169
- (showRowNumbersEffective ? rowNumberColumnWidth : 0) +
3170
- (showRowSelectionEffective ? selectionColumnWidth : 0);
3171
- let total = fixedCols;
3300
+ let total = systemColumnsWidth;
3172
3301
  for (const column of allColumns) total += getColumnWidth(column.id);
3173
3302
  // +1 to tolerate sub-pixel rounding residue from `fitColumns`.
3174
3303
  return total > viewportWidth + 1;
@@ -3620,9 +3749,13 @@ export function createSvGridController<
3620
3749
  : typeof rh === "function"
3621
3750
  ? rh
3622
3751
  : (rh ?? 30);
3623
- const estimateSize = rowResizeHeights.size > 0 || collapsedRows.size > 0
3752
+ // Detail rows have their own height; only when the props say so does
3753
+ // the per-index lookup replace the uniform fast path.
3754
+ const detailOn = props.detailRowHeight != null && !!props.isDetailRow;
3755
+ const estimateSize = rowResizeHeights.size > 0 || collapsedRows.size > 0 || detailOn
3624
3756
  ? (index: number) =>
3625
3757
  rowResizeHeightPx(index) ??
3758
+ detailRowHeightPx(index) ??
3626
3759
  (typeof base === "function" ? base(index) : base)
3627
3760
  : base;
3628
3761
  virtualizer.setOptions({
@@ -3761,6 +3894,14 @@ export function createSvGridController<
3761
3894
  // the only thing that can answer honestly once the data is a window
3762
3895
  // onto a server.
3763
3896
  if (props.rowSelectionModel) return props.rowSelectionModel.headerState();
3897
+ // Nothing checked is the common case and needs no walk: this derived
3898
+ // re-runs on every data change, and on a 100k-row live feed the filter
3899
+ // below was 30 ms per tick spent to conclude "none".
3900
+ let anyChecked = false;
3901
+ for (const id in rowSelectionState) {
3902
+ if (rowSelectionState[id]) { anyChecked = true; break; }
3903
+ }
3904
+ if (!anyChecked) return "none";
3764
3905
  const selectable = allRows.filter(
3765
3906
  (row) => !isGroupRow(row) && !placeholderStateOf(row),
3766
3907
  );
@@ -3810,8 +3951,6 @@ export function createSvGridController<
3810
3951
  // widths stay in the fit distribution and leave a blank gap at the edge.
3811
3952
  const cols = grid.getAllColumns().filter((c) => !hiddenColumns[c.id] && !hiddenByGroupCollapse[c.id] && !collapsedColumns[c.id]);
3812
3953
  if (!cols.length) return null;
3813
- const rowNumberWidth = showRowNumbersEffective ? rowNumberColumnWidth : 0;
3814
- const selectionWidth = showRowSelectionEffective ? selectionColumnWidth : 0;
3815
3954
  // Reserve the custom vertical scrollbar's width when it's visible. It
3816
3955
  // overlays the right 16px of the viewport (absolute, z-index 40) and
3817
3956
  // does NOT shrink clientWidth, so without this the last fitted column
@@ -3820,8 +3959,7 @@ export function createSvGridController<
3820
3959
  const scrollbarWidth = hasVerticalOverflow ? 16 : 0;
3821
3960
  const target =
3822
3961
  (scrollContainer?.clientWidth ?? 0) -
3823
- rowNumberWidth -
3824
- selectionWidth -
3962
+ systemColumnsWidth -
3825
3963
  scrollbarWidth;
3826
3964
  if (target <= 0) return null;
3827
3965
 
@@ -4319,6 +4457,8 @@ export function createSvGridController<
4319
4457
  set historyPtr(v) { historyPtr = v as never; },
4320
4458
  get historyGroupId() { return historyGroupId; },
4321
4459
  set historyGroupId(v) { historyGroupId = v as never; },
4460
+ get historyTag() { return historyTag; },
4461
+ set historyTag(v) { historyTag = v; },
4322
4462
  get historyVersion() { return historyVersion; },
4323
4463
  set historyVersion(v) { historyVersion = v as never; },
4324
4464
  get tooltip() { return tooltip; },
@@ -4355,6 +4495,11 @@ export function createSvGridController<
4355
4495
  get gridStateVersion() { return gridStateVersion; },
4356
4496
  set gridStateVersion(v) { gridStateVersion = v as never; },
4357
4497
  get selectionColumnWidth() { return selectionColumnWidth; },
4498
+ get detailToggleColumnWidth() { return detailToggleColumnWidth; },
4499
+ get detailToggleColumnLeft() { return detailToggleColumnLeft; },
4500
+ get showDetailToggleEffective() { return showDetailToggleEffective; },
4501
+ get systemColumnsWidth() { return systemColumnsWidth; },
4502
+ get detailToggleOf() { return detailToggleOf; },
4358
4503
  get rowNumberColumnWidth() { return rowNumberColumnWidth; },
4359
4504
  get showRowNumbersEffective() { return showRowNumbersEffective; },
4360
4505
  get filterOperatorOptions() { return filterOperatorOptions; },
@@ -4772,6 +4917,8 @@ export function createSvGridController<
4772
4917
  get frozenRowList() { return frozenRowList; },
4773
4918
  get frozenBandHeight() { return frozenBandHeight; },
4774
4919
  get frozenRowTop() { return frozenRowTop; },
4920
+ get stickyGroupRows() { return stickyGroupRows; },
4921
+ get stickyTopOfRow() { return stickyTopOfRow; },
4775
4922
  /** Record the height the user dragged a row to. */
4776
4923
  get setRowResizeHeight() { return setRowResizeHeight; },
4777
4924
  get isRowCollapsed() { return isRowCollapsed; },
@@ -4784,6 +4931,12 @@ export function createSvGridController<
4784
4931
  rowResizeVersion;
4785
4932
  return rowResizeHeightPx;
4786
4933
  },
4934
+ /** The height row i is drawn at: dragged, measured, detail, declared or default. */
4935
+ get rowSizePxOf() {
4936
+ rowResizeVersion;
4937
+ autoRowHeightVersion;
4938
+ return rowSizePxOf;
4939
+ },
4787
4940
  get virtualRowTotalSize() { return virtualRowTotalSize; },
4788
4941
  get virtualRowStart() { return virtualRowStart; },
4789
4942
  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. */
@@ -1327,6 +1403,15 @@ select.sv-grid-fr-editor {
1327
1403
  overflow: hidden so the chips picker would otherwise be clipped). */
1328
1404
  overflow: visible;
1329
1405
  }
1406
+ /* The active cell a grid mounts with is seeded at (0,0), not chosen: no
1407
+ ring until a click, an arrow key or the api activates a cell (the same
1408
+ gate the fill handle has always had). Once activated it stays through a
1409
+ blur, so a toolbar acting on "the focused row" keeps its cue. A page with
1410
+ six grids on it, or a detail grid inside each open row, used to open
1411
+ with an accent ring in every one of them. */
1412
+ .sv-grid-active-seeded .sv-grid-cell-active {
1413
+ box-shadow: none;
1414
+ }
1330
1415
 
1331
1416
  /* ---- The editing cell's border -------------------------------------------
1332
1417
  One ring for every editor, drawn by the CELL rather than by each editor.
@@ -1625,6 +1710,40 @@ select.sv-grid-fr-editor {
1625
1710
  border-bottom: 1px solid var(--sg-frozen-edge, var(--sg-border, #94a3b8));
1626
1711
  }
1627
1712
 
1713
+ /* A detail row with a declared height (detailRowHeight): the row keeps the
1714
+ height the virtualizer counted on - the auto-height rule above must not
1715
+ win - and the panel's body, a block inside the cell, takes that height
1716
+ and scrolls what does not fit. (A table cell's own height is only a
1717
+ minimum, so the height goes on the block.) */
1718
+ .sv-grid-row.sv-grid-detail-row.sv-grid-detail-row-sized {
1719
+ height: var(--sv-detail-h) !important;
1720
+ }
1721
+ .sv-grid-row.sv-grid-detail-row-sized > .sv-grid-detail-cell {
1722
+ vertical-align: top;
1723
+ box-sizing: border-box;
1724
+ height: var(--sv-detail-h);
1725
+ }
1726
+ .sv-grid-detail-body {
1727
+ box-sizing: border-box;
1728
+ height: 100%;
1729
+ overflow: auto;
1730
+ }
1731
+
1732
+ /* Sticky group rows: the group a row belongs to, held under the header
1733
+ (and under the frozen band) while its rows scroll past. Same layer as
1734
+ the frozen band; the last one carries the band's edge. */
1735
+ .sv-grid-row.sv-grid-row-sticky-group {
1736
+ position: sticky;
1737
+ z-index: 31;
1738
+ background: var(--sg-bg, #fff);
1739
+ }
1740
+ .sv-grid-row.sv-grid-row-sticky-group > .sv-grid-cell {
1741
+ background: var(--sg-bg, #fff);
1742
+ }
1743
+ .sv-grid-row.sv-grid-row-sticky-group-last > .sv-grid-cell {
1744
+ border-bottom: 1px solid var(--sg-frozen-edge, var(--sg-border, #94a3b8));
1745
+ }
1746
+
1628
1747
  /* The multiline text editor sizes itself by its lines and keeps the
1629
1748
  input's single-line look until a break is typed. */
1630
1749
  .sv-grid-cell-editor-multiline {
@@ -3082,6 +3201,12 @@ select.sv-grid-fr-editor {
3082
3201
  .sv-grid-row-number-cell {
3083
3202
  border-right-color: var(--sg-header-bg, #f1f5f9);
3084
3203
  }
3204
+ /* The detail-toggle gutter fuses with the row numbers the same way; only
3205
+ its own right edge keeps a line, between the chrome and the data. */
3206
+ .sv-grid-row-number-cell:has(+ .sv-grid-detail-toggle-cell),
3207
+ .sv-grid-row-number-column:has(+ .sv-grid-detail-toggle-column) {
3208
+ border-right-color: var(--sg-header-bg, #f1f5f9);
3209
+ }
3085
3210
 
3086
3211
  /* Group row label colour. */
3087
3212
  .sv-grid-group-row > .sv-grid-cell {