@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/src/SvGrid.css CHANGED
@@ -902,8 +902,8 @@
902
902
  pattern isn't being applied (it matches nothing until it compiles). */
903
903
  .sv-grid-filter-value.sv-grid-filter-value-invalid,
904
904
  .sv-grid-tp-filter-input.sv-grid-filter-value-invalid {
905
- border-color: var(--sg-danger, #e5484d) !important;
906
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--sg-danger, #e5484d) 45%, transparent);
905
+ border-color: var(--sg-danger, #dc2626) !important;
906
+ box-shadow: 0 0 0 1px color-mix(in srgb, var(--sg-danger, #dc2626) 45%, transparent);
907
907
  }
908
908
 
909
909
  /* In / Not-in chip (token) input: chips + a flex-growing text box share one
@@ -2130,6 +2130,104 @@ select.sv-grid-fr-editor {
2130
2130
  background: var(--sg-selection-bg, #eaf2ff);
2131
2131
  }
2132
2132
 
2133
+ /* Group subtotal row (`groupFooters`). Renders through the normal cell path so
2134
+ its totals sit under their own columns; a top rule + weight is what reads as
2135
+ "this closes the group above". Lighter than the banner on purpose - the
2136
+ banner opens a group, the footer just totals it. */
2137
+ .sv-grid-group-footer-row > .sv-grid-cell {
2138
+ background: var(--sg-header-bg, #eef2f8);
2139
+ border-top: 1px solid var(--sg-border, #d7dce5);
2140
+ font-weight: 600;
2141
+ }
2142
+
2143
+ /* Auto row height (`autoRowHeight`). The default cell truncates to one line
2144
+ (nowrap + ellipsis + overflow hidden), which would make a measured row height
2145
+ pointless - every row would measure the same. Let the text wrap so the row
2146
+ has something to grow to, and pad vertically since there is no fixed height
2147
+ centring the single line any more. */
2148
+ .sv-grid-row-auto-height > .sv-grid-cell {
2149
+ white-space: normal;
2150
+ overflow-wrap: anywhere;
2151
+ overflow: visible;
2152
+ text-overflow: clip;
2153
+ padding-top: 6px;
2154
+ padding-bottom: 6px;
2155
+ /* Cells stretch to the row's height rather than centring on one line. */
2156
+ vertical-align: top;
2157
+ }
2158
+
2159
+ /* Grand total (`grandTotalRow`). One step heavier than a group subtotal: it
2160
+ closes the whole dataset, not one group, so it takes a double top rule. */
2161
+ .sv-grid-grand-total-row > .sv-grid-cell {
2162
+ border-top: 3px double var(--sg-border, #d7dce5);
2163
+ font-weight: 700;
2164
+ }
2165
+
2166
+ /* Client tree data (`treeData`). The indent is a sized spacer rather than
2167
+ padding on the cell so conditional-format backgrounds and data bars still
2168
+ span the full cell width. */
2169
+ .sv-grid-tree-indent {
2170
+ display: inline-block;
2171
+ flex: none;
2172
+ }
2173
+ .sv-grid-tree-toggle {
2174
+ display: inline-flex;
2175
+ align-items: center;
2176
+ justify-content: center;
2177
+ width: 14px;
2178
+ height: 14px;
2179
+ flex: none;
2180
+ margin-right: 3px;
2181
+ padding: 0;
2182
+ border: 0;
2183
+ border-radius: 3px;
2184
+ background: transparent;
2185
+ color: var(--sg-muted, #64748b);
2186
+ font-size: 10px;
2187
+ line-height: 1;
2188
+ cursor: pointer;
2189
+ }
2190
+ .sv-grid-tree-toggle:hover {
2191
+ background: var(--sg-header-bg, #eef2f8);
2192
+ color: var(--sg-fg, #0f172a);
2193
+ }
2194
+ /* One chevron for every expander in the library - the grid's tree rows, its
2195
+ group banners and auto-group columns, SvGroupCell and SvTree all draw the
2196
+ same `chevron-right` and rotate it open, rather than swapping between two
2197
+ different glyphs. */
2198
+ .sv-grid-tree-toggle .sv-grid-icon,
2199
+ .sv-grid-group-toggle .sv-grid-icon {
2200
+ width: 12px;
2201
+ height: 12px;
2202
+ transition: transform 0.15s ease;
2203
+ }
2204
+ .sv-grid-tree-toggle[aria-expanded="true"] .sv-grid-icon,
2205
+ .sv-grid-group-toggle[aria-expanded="true"] .sv-grid-icon {
2206
+ transform: rotate(90deg);
2207
+ }
2208
+ .sv-grid-tree-toggle:focus-visible {
2209
+ outline: 2px solid var(--sg-accent, #2563eb);
2210
+ outline-offset: 1px;
2211
+ }
2212
+ /* Auto-group column cell (`groupDisplayMode: singleColumn | multipleColumns`).
2213
+ The group row is an ordinary row here, so only the label needs weight - the
2214
+ full-width banner styling does not apply. */
2215
+ .sv-grid-autogroup-label {
2216
+ font-weight: 600;
2217
+ color: var(--sg-fg, #0f172a);
2218
+ overflow: hidden;
2219
+ text-overflow: ellipsis;
2220
+ white-space: nowrap;
2221
+ }
2222
+
2223
+ /* Keeps a childless row's value on the same x as its expandable siblings. */
2224
+ .sv-grid-tree-spacer {
2225
+ display: inline-block;
2226
+ width: 14px;
2227
+ flex: none;
2228
+ margin-right: 3px;
2229
+ }
2230
+
2133
2231
  .sv-grid-group-cell {
2134
2232
  padding: 0 12px;
2135
2233
  }
@@ -2209,7 +2307,9 @@ select.sv-grid-fr-editor {
2209
2307
  line-height: 1.4;
2210
2308
  resize: vertical;
2211
2309
  outline: none;
2212
- box-shadow: var(--sg-focus-ring, 0 0 0 2px rgba(99, 102, 241, 0.40));
2310
+ /* --sg-focus-ring is a COLOR everywhere else (`outline: 2px solid ...`).
2311
+ Consume it as one here too, rather than as a whole box-shadow value. */
2312
+ box-shadow: 0 0 0 2px var(--sg-focus-ring, rgba(99, 102, 241, 0.40));
2213
2313
  }
2214
2314
 
2215
2315
  .sv-grid-autocomplete {
package/src/SvGrid.svelte CHANGED
@@ -256,13 +256,32 @@
256
256
  // Server-side group / tree a11y: aria-level on every tree row, aria-expanded
257
257
  // on the expandable ones. Undefined (attribute omitted) when serverGroup is off.
258
258
  const serverGroup = $derived(opt.serverGroup);
259
+ // Client tree data reuses this same treegrid contract - the row model already
260
+ // records depth and expandability, so the accessors just read the row instead
261
+ // of calling back into consumer callbacks.
262
+ const treeData = $derived(opt.treeData);
259
263
  function sgAriaLevel(row: Row<TData>): number | undefined {
264
+ if (treeData) return (row.depth ?? 0) + 1;
260
265
  return serverGroup ? serverGroup.level(row.original as TData) + 1 : undefined;
261
266
  }
262
267
  function sgAriaExpanded(row: Row<TData>): boolean | undefined {
268
+ if (treeData) {
269
+ if (!row.getCanExpand?.()) return undefined;
270
+ return row.getIsExpanded?.() ?? false;
271
+ }
263
272
  if (!serverGroup || !serverGroup.isGroup(row.original as TData)) return undefined;
264
273
  return serverGroup.expanded?.(row.original as TData) ?? false;
265
274
  }
275
+ /** Column that carries the tree expander: explicit, else the first visible one. */
276
+ const treeColumnId = $derived(
277
+ treeData ? (treeData.column ?? allColumns[0]?.id ?? "") : "",
278
+ );
279
+ // Row-grouping display modes: group state lives in synthetic columns instead
280
+ // of a full-width banner row.
281
+ const groupColumnMode = $derived(ctrl.groupColumnMode);
282
+ const autoGroupCell = $derived(ctrl.autoGroupCell);
283
+ const isAutoGroupColumn = (id: string): boolean =>
284
+ id === "__autoGroup" || id.startsWith("__group_");
266
285
  const sortDirectionByColumn = $derived(ctrl.sortDirectionByColumn);
267
286
  const groupingColumns = $derived(ctrl.groupingColumns);
268
287
  const paginationState = $derived(ctrl.paginationState);
@@ -286,10 +305,25 @@
286
305
  const rh = opt.rowHeight;
287
306
  return typeof rh === "function" ? rh(i) : (rh ?? 30);
288
307
  };
308
+ // Auto height applies to both bodies. The non-virtualized one has no
309
+ // `rowItem`, so it sizes from `rowSizePx` - but must still emit `min-height`
310
+ // and the wrap class, or `autoRowHeight` would silently do nothing whenever
311
+ // `virtualization={false}`.
312
+ const rowStyleFor = (i: number): string =>
313
+ autoRowHeightOn
314
+ ? `min-height: ${measuredRowHeightPx(i) ?? rowSizePx(i)}px;`
315
+ : `height: ${rowSizePx(i)}px;`;
289
316
  const columnVirtualizationEnabled = $derived(
290
317
  ctrl.columnVirtualizationEnabled,
291
318
  );
292
319
  const virtualRows = $derived(ctrl.virtualRows);
320
+ const autoRowHeightOn = $derived(ctrl.autoRowHeightOn);
321
+ const measureRowHeight = $derived(ctrl.measureRowHeight);
322
+ const measuredRowHeightPx = $derived(ctrl.measuredRowHeightPx);
323
+ // Under auto height the row must be free to grow, so it gets `min-height`
324
+ // (the pre-measure estimate) instead of a fixed `height` that would clip it.
325
+ const rowHeightStyle = (size: number): string =>
326
+ autoRowHeightOn ? `min-height: ${size}px;` : `height: ${size}px;`;
293
327
  // DOM-space spacer heights + capped total: identical to the logical
294
328
  // virtualizer values for normal grids, scaled down past
295
329
  // MAX_DOM_SCROLL_HEIGHT so huge grids stay scrollable to the last row on
@@ -405,6 +439,16 @@
405
439
  const hasMeasured = $derived(ctrl.hasMeasured);
406
440
  const onBodyScroll = $derived(ctrl.onBodyScroll);
407
441
  const computeRowClass = $derived(ctrl.computeRowClass);
442
+ // Group subtotal rows are clones of their banner, so they render through the
443
+ // normal cell path and their aggregates land under the right columns; only
444
+ // the class distinguishes them. Typed here rather than cast inline - an `as`
445
+ // inside template markup type-checks but breaks the vitest/build parse.
446
+ const isGroupFooterRow = (row: Row<TData>): boolean =>
447
+ (row as { __groupFooter?: boolean }).__groupFooter === true;
448
+ // The grand total is a group footer that closes the whole set rather than one
449
+ // group, so it carries both classes - shared chrome, heavier top rule.
450
+ const isGrandTotalRow = (row: Row<TData>): boolean =>
451
+ (row as { __grandTotal?: boolean }).__grandTotal === true;
408
452
  const computeCellClass = $derived(ctrl.computeCellClass);
409
453
  const computeCellTooltip = $derived(ctrl.computeCellTooltip);
410
454
  const computeCellValidity = $derived(ctrl.computeCellValidity);
@@ -412,6 +456,7 @@
412
456
  const getCellDisplayValue = $derived(ctrl.getCellDisplayValue);
413
457
  const getColumnAlign = $derived(ctrl.getColumnAlign);
414
458
  const getColumnEditorOptions = $derived(ctrl.getColumnEditorOptions);
459
+ const areEditorOptionsLoading = $derived(ctrl.areEditorOptionsLoading);
415
460
  const formatListCellValue = $derived(ctrl.formatListCellValue);
416
461
  const formatCellValue = $derived(ctrl.formatCellValue);
417
462
  const getPinnedCellValue = $derived(ctrl.getPinnedCellValue);
@@ -460,6 +505,21 @@
460
505
  ctrl.editingCell = null;
461
506
  ctrl.gridRootEl?.focus({ preventScroll: true });
462
507
  },
508
+ // Tab out of an editor: commit, then step to the adjacent cell. Without
509
+ // this a registered editor's Tab walks focus out of the grid (#48) - the
510
+ // built-in editors already route through the same helper.
511
+ onCommitAndMove: (v: unknown, direction: 1 | -1) => {
512
+ if (v !== undefined) updateEditingCellValue(v);
513
+ ctrl.commitAndMoveByTab(direction === -1);
514
+ },
515
+ // Close a popover editor without committing. Same effect as cancel today;
516
+ // named separately so an editor can distinguish "dismiss my panel" from
517
+ // "abandon the edit" if that ever diverges.
518
+ onRequestClose: () => {
519
+ ctrl.editingCell = null;
520
+ ctrl.gridRootEl?.focus({ preventScroll: true });
521
+ },
522
+ inCell: true,
463
523
  };
464
524
  }
465
525
  const onHeaderSortClick = $derived(ctrl.onHeaderSortClick);
@@ -869,6 +929,11 @@
869
929
  <path d="M6 6l12 12" />
870
930
  {:else if name === "chevron-down"}
871
931
  <path d="M6 9l6 6 6-6" />
932
+ {:else if name === "chevron-right"}
933
+ <!-- Same path SvTree's twisty uses, so every expander in the library
934
+ draws the same chevron. Rotated 90deg by CSS when expanded rather
935
+ than swapped for a second glyph. -->
936
+ <path d="m9 18 6-6-6-6" />
872
937
  {:else if name === "op-contains"}
873
938
  <circle cx="11" cy="11" r="6" />
874
939
  <path d="M20 20l-4.5-4.5" />
@@ -1027,7 +1092,12 @@
1027
1092
  </svg>
1028
1093
  {/if}
1029
1094
  {:else}
1030
- {#if row.depth > 0 && column.id === allColumns[0]?.id}
1095
+ <!-- Indent for rows nested under a group BANNER. Tree data and the
1096
+ column display modes draw their own indent next to the expander, so
1097
+ this must not stack on top of theirs - `row.depth` is set in all
1098
+ three cases and this used to fire for every one of them, leaving a
1099
+ 20px gap between a tree row's chevron and its text. -->
1100
+ {#if row.depth > 0 && column.id === allColumns[0]?.id && !treeData && !groupColumnMode}
1031
1101
  <span
1032
1102
  class="sv-grid-group-child-indent"
1033
1103
  style={`width: ${row.depth * 20}px;`}
@@ -1073,10 +1143,75 @@
1073
1143
  color / weight and the icon-set glyph ride on the content wrapper.
1074
1144
  Falls straight through to `cellBody` when no format applies, so the
1075
1145
  common (unformatted) path pays nothing. -->
1146
+ <!-- Tree expander + indentation, prefixed to the tree column's cell. Rows
1147
+ without children still get the indent (and a spacer where the chevron
1148
+ would be) so values stay aligned down a level. -->
1149
+ {#snippet treeAffordance(row: Row<TData>)}
1150
+ {@const canExpand = row.getCanExpand?.() ?? false}
1151
+ <span
1152
+ class="sv-grid-tree-indent"
1153
+ style={`width:${(row.depth ?? 0) * (treeData?.indentPx ?? 12)}px;`}
1154
+ aria-hidden="true"
1155
+ ></span>
1156
+ {#if canExpand}
1157
+ <button
1158
+ type="button"
1159
+ class="sv-grid-tree-toggle"
1160
+ aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1161
+ aria-label={row.getIsExpanded?.() ? "Collapse row" : "Expand row"}
1162
+ onclick={(event) => {
1163
+ event.stopPropagation();
1164
+ row.toggleExpanded?.();
1165
+ }}>{@render icon("chevron-right")}</button
1166
+ >
1167
+ {:else}
1168
+ <span class="sv-grid-tree-spacer" aria-hidden="true"></span>
1169
+ {/if}
1170
+ {/snippet}
1171
+
1172
+ <!-- The group label + expander for a `singleColumn` / `multipleColumns`
1173
+ auto-group cell. Indented by level in single-column mode; in multi-column
1174
+ mode each level already has its own column, so no indent. -->
1175
+ {#snippet autoGroupAffordance(row: Row<TData>, cell: { label: string; count: number; depth: number })}
1176
+ <span
1177
+ class="sv-grid-tree-indent"
1178
+ style={`width:${cell.depth * 16}px;`}
1179
+ aria-hidden="true"
1180
+ ></span>
1181
+ <button
1182
+ type="button"
1183
+ class="sv-grid-tree-toggle"
1184
+ aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
1185
+ aria-label={row.getIsExpanded?.() ? "Collapse group" : "Expand group"}
1186
+ onclick={(event) => {
1187
+ event.stopPropagation();
1188
+ row.toggleExpanded?.();
1189
+ }}>{@render icon("chevron-right")}</button
1190
+ >
1191
+ <span class="sv-grid-autogroup-label">{cell.label}</span>
1192
+ <span class="sv-grid-group-count">({cell.count})</span>
1193
+ {/snippet}
1194
+
1076
1195
  {#snippet cellBodyWithFormat(
1077
1196
  row: Row<TData>,
1078
1197
  column: Column<TData>,
1079
1198
  cellValue: unknown,
1199
+ )}
1200
+ {#if groupColumnMode && isAutoGroupColumn(column.id)}
1201
+ {@const agc = autoGroupCell(row, column.id)}
1202
+ {#if agc}{@render autoGroupAffordance(row, agc)}{/if}
1203
+ {:else}
1204
+ {#if treeData && column.id === treeColumnId}
1205
+ {@render treeAffordance(row)}
1206
+ {/if}
1207
+ {@render cellBodyFormatted(row, column, cellValue)}
1208
+ {/if}
1209
+ {/snippet}
1210
+
1211
+ {#snippet cellBodyFormatted(
1212
+ row: Row<TData>,
1213
+ column: Column<TData>,
1214
+ cellValue: unknown,
1080
1215
  )}
1081
1216
  {@const cf = cellConditionalFormat(row, column, cellValue)}
1082
1217
  {#if cf && (cf.background || cf.dataBar || cf.icon || cf.color || cf.fontWeight != null)}
@@ -1175,10 +1310,12 @@
1175
1310
  ></button>
1176
1311
  {:else if ctrl.editingCell?.editorType === "list"}
1177
1312
  {@const opts = getColumnEditorOptions(column, row)}
1313
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
1178
1314
  {@const multi = column.columnDef.editorMultiple === true}
1179
1315
  {#if DropdownEditor}
1180
1316
  <DropdownEditor
1181
1317
  options={opts}
1318
+ loading={optsLoading}
1182
1319
  value={ctrl.editingCell?.value}
1183
1320
  multiple={multi}
1184
1321
  placeholder="Select…"
@@ -1196,9 +1333,10 @@
1196
1333
  {/if}
1197
1334
  {:else if ctrl.editingCell?.editorType === "chips"}
1198
1335
  {@const opts = getColumnEditorOptions(column, row)}
1336
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
1199
1337
  {@const multi = column.columnDef.editorMultiple === true}
1200
1338
  {@const selectedArr = toValueArray(ctrl.editingCell?.value)}
1201
- {@render chipsEditor(opts, multi, selectedArr)}
1339
+ {@render chipsEditor(opts, multi, selectedArr, optsLoading)}
1202
1340
  {:else if ctrl.editingCell?.editorType === "rating"}
1203
1341
  {@const ratingVal = Math.max(
1204
1342
  0,
@@ -1241,9 +1379,11 @@
1241
1379
  <!-- Custom dropdown: opens a themed popover identical in feel to
1242
1380
  the existing 'list' editor (single-select, no typeahead). -->
1243
1381
  {@const selectOpts = getColumnEditorOptions(column, row)}
1382
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
1244
1383
  {#if DropdownEditor}
1245
1384
  <DropdownEditor
1246
1385
  options={selectOpts}
1386
+ loading={optsLoading}
1247
1387
  value={ctrl.editingCell?.value}
1248
1388
  multiple={false}
1249
1389
  placeholder="Select…"
@@ -1263,9 +1403,11 @@
1263
1403
  <!-- Searchable combobox: same popover as 'select' with a
1264
1404
  typeahead filter input baked in at the top. -->
1265
1405
  {@const richOpts = getColumnEditorOptions(column, row)}
1406
+ {@const optsLoading = areEditorOptionsLoading(column, row)}
1266
1407
  {#if DropdownEditor}
1267
1408
  <DropdownEditor
1268
1409
  options={richOpts}
1410
+ loading={optsLoading}
1269
1411
  value={ctrl.editingCell?.value}
1270
1412
  multiple={false}
1271
1413
  searchable={true}
@@ -1527,6 +1669,7 @@
1527
1669
  opts: CellEditorOption[],
1528
1670
  multi: boolean,
1529
1671
  selectedArr: Array<string | number>,
1672
+ optsLoading: boolean,
1530
1673
  )}
1531
1674
  {#if opts.length > 0}
1532
1675
  <!-- Options-driven chips editor: defer to the custom dropdown,
@@ -1536,6 +1679,7 @@
1536
1679
  {#if DropdownEditor}
1537
1680
  <DropdownEditor
1538
1681
  options={opts}
1682
+ loading={optsLoading}
1539
1683
  value={ctrl.editingCell?.value}
1540
1684
  multiple={multi}
1541
1685
  placeholder="Pick…"
@@ -1657,7 +1801,7 @@
1657
1801
  onclick={(event) => {
1658
1802
  event.stopPropagation();
1659
1803
  row.toggleExpanded?.();
1660
- }}>{row.getIsExpanded?.() ? "" : "▸"}</button
1804
+ }}>{@render icon("chevron-right")}</button
1661
1805
  >
1662
1806
  <span class="sv-grid-group-label">{headerLabel}: {groupValue}</span>
1663
1807
  <span class="sv-grid-group-count"
@@ -1853,7 +1997,7 @@
1853
1997
  activeDescendantId,
1854
1998
  rowCount: allRows.length,
1855
1999
  colCount: allColumns.length,
1856
- treegrid: !!opt.serverGroup,
2000
+ treegrid: !!opt.serverGroup || !!opt.treeData,
1857
2001
  })}
1858
2002
  onkeydown={onGridKeyDown}
1859
2003
  onpaste={onGridPaste}
@@ -2438,14 +2582,14 @@
2438
2582
  {#if row}
2439
2583
  {#if opt.isDetailRow?.(row.original as TData, rowIndex)}
2440
2584
  {@render detailRowMarkup(row, rowIndex)}
2441
- {:else if isGroupRow(row)}
2585
+ {:else if isGroupRow(row) && !groupColumnMode}
2442
2586
  <tr
2443
2587
  class="sv-grid-row sv-grid-group-row"
2444
2588
  class:sv-grid-row-selected={isRowSelected(row.id)}
2445
2589
  aria-level={row.depth + 1}
2446
2590
  aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
2447
2591
  {...getGridRowA11yProps(rowIndex + 1)}
2448
- style={`height: ${rowItem.size}px;`}
2592
+ style={rowHeightStyle(rowItem.size)}
2449
2593
  >
2450
2594
  <td
2451
2595
  class="sv-grid-cell sv-grid-group-cell"
@@ -2467,11 +2611,15 @@
2467
2611
  class:sv-grid-row-alt={opt.zebraRows &&
2468
2612
  rowIndex % 2 === 1}
2469
2613
  class:sv-grid-row-draggable={rowDragManagedEffective}
2614
+ class:sv-grid-group-footer-row={isGroupFooterRow(row)}
2615
+ class:sv-grid-grand-total-row={isGrandTotalRow(row)}
2616
+ class:sv-grid-row-auto-height={autoRowHeightOn}
2470
2617
  {...getGridRowA11yProps(rowIndex + 1)}
2471
2618
  aria-level={sgAriaLevel(row)}
2472
2619
  aria-expanded={sgAriaExpanded(row)}
2473
2620
  {...rowDragAttrs(rowIndex)}
2474
- style={`height: ${rowItem.size}px;`}
2621
+ style={rowHeightStyle(rowItem.size)}
2622
+ use:measureRowHeight={rowIndex}
2475
2623
  >
2476
2624
  {#if showRowNumbersEffective}
2477
2625
  <td
@@ -2690,14 +2838,14 @@
2690
2838
  {#each allRows as row, rowIndex (row.id)}
2691
2839
  {#if opt.isDetailRow?.(row.original as TData, rowIndex)}
2692
2840
  {@render detailRowMarkup(row, rowIndex)}
2693
- {:else if isGroupRow(row)}
2841
+ {:else if isGroupRow(row) && !groupColumnMode}
2694
2842
  <tr
2695
2843
  class="sv-grid-row sv-grid-group-row"
2696
2844
  class:sv-grid-row-selected={isRowSelected(row.id)}
2697
2845
  aria-level={row.depth + 1}
2698
2846
  aria-expanded={row.getIsExpanded?.() ? "true" : "false"}
2699
2847
  {...getGridRowA11yProps(rowIndex + 1)}
2700
- style={`height: ${rowSizePx(rowIndex)}px;`}
2848
+ style={rowStyleFor(rowIndex)}
2701
2849
  >
2702
2850
  <td
2703
2851
  class="sv-grid-cell sv-grid-group-cell"
@@ -2719,11 +2867,15 @@
2719
2867
  class:sv-grid-row-alt={opt.zebraRows &&
2720
2868
  rowIndex % 2 === 1}
2721
2869
  class:sv-grid-row-draggable={rowDragManagedEffective}
2870
+ class:sv-grid-group-footer-row={isGroupFooterRow(row)}
2871
+ class:sv-grid-grand-total-row={isGrandTotalRow(row)}
2872
+ class:sv-grid-row-auto-height={autoRowHeightOn}
2722
2873
  {...getGridRowA11yProps(rowIndex + 1)}
2723
2874
  aria-level={sgAriaLevel(row)}
2724
2875
  aria-expanded={sgAriaExpanded(row)}
2725
2876
  {...rowDragAttrs(rowIndex)}
2726
- style={`height: ${rowSizePx(rowIndex)}px;`}
2877
+ style={rowStyleFor(rowIndex)}
2878
+ use:measureRowHeight={rowIndex}
2727
2879
  >
2728
2880
  {#if showRowNumbersEffective}
2729
2881
  <td
@@ -9,6 +9,7 @@ import type {
9
9
  TableFeatures,
10
10
  } from "./index";
11
11
  import type { ConditionalFormat } from "./conditional-formatting";
12
+ import type { GroupDisplayType } from "./group-display";
12
13
  import type { GridMessages } from "./grid-messages";
13
14
  import type { GridPivotConfig } from "./pivot-view.svelte";
14
15
  import type { MenuItem } from "./SvMenuList.svelte";
@@ -1029,7 +1030,75 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1029
1030
  sortable?: boolean;
1030
1031
  filterable?: boolean;
1031
1032
  editable?: boolean;
1033
+ /**
1034
+ * Client-side tree data: nest rows into a hierarchy by parent id.
1035
+ *
1036
+ * ```svelte
1037
+ * <SvGrid {data} {columns} treeData={{ parentField: 'managerId', column: 'name' }} />
1038
+ * ```
1039
+ *
1040
+ * Unlike grouping, tree rows are real data rows - they keep their cells,
1041
+ * editing and selection, and only gain an expander plus indentation in
1042
+ * `column`. Rows whose parent is missing become roots rather than vanishing.
1043
+ *
1044
+ * For NESTED source data (`children: [...]`), flatten it first with
1045
+ * `flattenTreeData(data, { childrenField: 'children' })` and point
1046
+ * `parentField` at the `__parentId` it stamps on.
1047
+ *
1048
+ * Setting this replaces row grouping - a row cannot be both.
1049
+ */
1050
+ treeData?: {
1051
+ /** Field holding each row's parent id. */
1052
+ parentField: string;
1053
+ /** Field holding the row's own id. Defaults to `'id'`. */
1054
+ idField?: string;
1055
+ /**
1056
+ * Column id that carries the expander + indentation. Defaults to the first
1057
+ * visible column.
1058
+ */
1059
+ column?: string;
1060
+ /** Indent per depth level, in px. Default `12`. */
1061
+ indentPx?: number;
1062
+ };
1032
1063
  groupable?: boolean;
1064
+ /**
1065
+ * Render a subtotal row after each group's children, carrying that group's
1066
+ * aggregate values under the columns they belong to (the columns with an
1067
+ * `aggregate` set). Off by default.
1068
+ *
1069
+ * Footers are inserted after paging, so they never count against `pageSize` -
1070
+ * a page shows its `pageSize` data rows plus whatever footers close on it.
1071
+ */
1072
+ groupFooters?: boolean;
1073
+ /**
1074
+ * Append a grand-total row at the very end, aggregating the whole filtered
1075
+ * set (not just the current page) with each column's `aggregate` function.
1076
+ *
1077
+ * Independent of `groupFooters`: use it on a flat grid for a bottom totals
1078
+ * line, or alongside group subtotals for both. Like group footers it is
1079
+ * appended after paging, so it never counts against `pageSize` - but it is
1080
+ * only appended on the LAST page, so a total never appears mid-dataset.
1081
+ * Columns without an `aggregate` are left blank.
1082
+ */
1083
+ grandTotalRow?: boolean;
1084
+ /**
1085
+ * How grouped rows are displayed.
1086
+ *
1087
+ * - `groupRows` (default): a full-width banner row per group. Unchanged
1088
+ * behaviour - existing grids are untouched.
1089
+ * - `singleColumn`: one synthetic "Group" column holding every level,
1090
+ * indented by depth.
1091
+ * - `multipleColumns`: one synthetic column per grouped field.
1092
+ *
1093
+ * Both column modes hide the grouped source columns, since their values move
1094
+ * into the auto column(s), and render group rows as ordinary rows so their
1095
+ * aggregate cells line up under the real columns.
1096
+ */
1097
+ groupDisplayMode?: GroupDisplayType;
1098
+ /** Header for the combined `singleColumn` auto-group column. Default `"Group"`. */
1099
+ autoGroupColumnHeader?: string;
1100
+ /** Width (px) of each auto-group column. Default `220`. */
1101
+ autoGroupColumnWidth?: number;
1033
1102
  pageable?: boolean;
1034
1103
  loading?: boolean;
1035
1104
  /**
@@ -1107,6 +1176,18 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
1107
1176
  * per-row variable heights (e.g. an interactive row-resize feature).
1108
1177
  * Defaults to 30. */
1109
1178
  rowHeight?: number | ((rowIndex: number) => number);
1179
+ /**
1180
+ * Size each row to its own content instead of a fixed height: cell text wraps
1181
+ * and the row grows to fit the tallest cell. Rows are measured after they
1182
+ * render, so this works with virtualization - `rowHeight` (or 30) is the
1183
+ * estimate used before a row has been measured, which keeps the scrollbar
1184
+ * stable while you scroll into new rows.
1185
+ *
1186
+ * Costs a measurement pass per row, so prefer a fixed `rowHeight` when your
1187
+ * content is uniform. Ignored when `rowHeight` is a function (you are already
1188
+ * supplying per-row heights).
1189
+ */
1190
+ autoRowHeight?: boolean;
1110
1191
  /**
1111
1192
  * Height (px) of a single column-header level row. With multi-level
1112
1193
  * (grouped) headers the total header height is `levels * headerHeight`,
@@ -41,6 +41,10 @@
41
41
  onCommit?: () => void
42
42
  /** Called when the user dismisses (Escape, blur with no change). */
43
43
  onCancel?: () => void
44
+ /** The option list is still being fetched (async `editorOptions`). Shows a
45
+ * loading line instead of "No options", which would read as "nothing to
46
+ * pick" when the truth is "not here yet". */
47
+ loading?: boolean
44
48
  }
45
49
 
46
50
  let {
@@ -54,6 +58,7 @@
54
58
  onChange,
55
59
  onCommit,
56
60
  onCancel,
61
+ loading = false,
57
62
  }: Props = $props()
58
63
 
59
64
  /** Stable per-instance id used to build option ids for
@@ -158,9 +163,13 @@
158
163
  function updatePanelPosition() {
159
164
  if (!triggerEl) return
160
165
  // Use the smaller of (option count, 10) for the upward-flip math so
161
- // a 3-option list doesn't think it needs 320px of headroom.
162
- const visibleCount = Math.min(options.length, 10)
163
- const estimatedHeight = visibleCount * optionRowHeight + (multiple ? 40 : 0) + 8
166
+ // a 3-option list doesn't think it needs 320px of headroom. Measured off
167
+ // the VISIBLE options - the search-filtered list is what actually renders,
168
+ // and an async list is empty at open time.
169
+ const visibleCount = Math.min(visibleOptions.length, 10)
170
+ const searchHeight = searchable ? 38 : 0
171
+ const estimatedHeight =
172
+ visibleCount * optionRowHeight + (multiple ? 40 : 0) + searchHeight + 8
164
173
  panelRect = anchoredRect(triggerEl.getBoundingClientRect(), {
165
174
  estimatedHeight,
166
175
  // Preserve the original behavior: anchor to the trigger, no clamping.
@@ -181,6 +190,20 @@
181
190
  }
182
191
  })
183
192
 
193
+ // Re-anchor when the option list itself changes while the panel is open.
194
+ // The geometry is derived from the option COUNT, so an async list - which
195
+ // lands after the panel opened, and a search filter, which shrinks it - would
196
+ // otherwise leave the panel sized for the list it had a moment ago. With an
197
+ // async source that means a panel sized for ZERO options: 8px tall, with the
198
+ // arriving options spilling outside its background and the grid showing
199
+ // through behind them.
200
+ $effect(() => {
201
+ if (!open) return
202
+ void visibleOptions.length
203
+ void optionRowHeight
204
+ updatePanelPosition()
205
+ })
206
+
184
207
  function closePanel() {
185
208
  open = false
186
209
  highlighted = -1
@@ -387,9 +410,15 @@
387
410
  {@const needsScroll = options.length > 10}
388
411
  <!-- Cap to the comfortable 10-row height AND the room the viewport allows,
389
412
  so a panel near the bottom edge scrolls instead of overflowing. -->
413
+ <!-- Capped by the REAL room the viewport allows (`availHeight`), never by
414
+ `maxHeight` - that one is clamped to our estimated height, and the
415
+ estimate assumes a 32px row. Themes with a larger font render taller
416
+ rows, so the estimate came up a few px short and the last option was
417
+ cut off by the panel edge. The estimate still decides which way to
418
+ flip; it must not decide how tall the panel is allowed to be. -->
390
419
  {@const panelMax = Math.min(
391
420
  needsScroll ? 10 * optionRowHeight + (multiple ? 40 : 0) + 8 : Number.POSITIVE_INFINITY,
392
- panelRect.maxHeight,
421
+ panelRect.availHeight,
393
422
  )}
394
423
  <div
395
424
  class="sv-grid-dropdown-panel"
@@ -400,7 +429,8 @@
400
429
  use:portalToBody
401
430
  use:popIn={{ up: panelRect.openUpward }}
402
431
  style:position="fixed"
403
- style:top={`${panelRect.top}px`}
432
+ style:top={panelRect.openUpward ? null : `${panelRect.top}px`}
433
+ style:bottom={panelRect.openUpward ? `${panelRect.bottom}px` : null}
404
434
  style:left={`${panelRect.left}px`}
405
435
  style:width={`${panelRect.width}px`}
406
436
  style:max-height={panelMax !== null ? `${panelMax}px` : null}
@@ -422,7 +452,9 @@
422
452
  }}
423
453
  />
424
454
  {/if}
425
- {#if visibleOptions.length === 0}
455
+ {#if loading && visibleOptions.length === 0}
456
+ <div class="sv-grid-dropdown-empty" role="status" aria-live="polite">Loading…</div>
457
+ {:else if visibleOptions.length === 0}
426
458
  <div class="sv-grid-dropdown-empty">{searchable && searchQuery ? `No matches for "${searchQuery}"` : 'No options'}</div>
427
459
  {:else}
428
460
  {#each visibleOptions as opt, i (String(opt.value))}