@svgrid/grid 3.0.4 → 4.0.0

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 (71) hide show
  1. package/README.md +3 -3
  2. package/dist/SvGrid.controller.svelte.d.ts +12 -2
  3. package/dist/SvGrid.controller.svelte.js +191 -8
  4. package/dist/SvGrid.css +73 -0
  5. package/dist/SvGrid.svelte +82 -3
  6. package/dist/SvGrid.types.d.ts +105 -3
  7. package/dist/build-api.js +11 -6
  8. package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-CLI93jke.js} +26 -26
  9. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CwooL3QA.js} +26 -26
  10. package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
  11. package/dist/cdn/{src-BKhZ6eXd.js → src-B6HXdfHY.js} +4099 -4140
  12. package/dist/cdn/{src-V1uu8iE9.js → src-C6Iowdvs.js} +4052 -4093
  13. package/dist/cdn/svgrid.js +27 -26
  14. package/dist/cdn/svgrid.svelte-external.js +27 -26
  15. package/dist/cdn/validate-CchXzyrX.js +76 -0
  16. package/dist/core.js +36 -13
  17. package/dist/editing.js +10 -0
  18. package/dist/grid-icons.d.ts +2 -2
  19. package/dist/grid-icons.js +2 -2
  20. package/dist/grid-messages.d.ts +2 -0
  21. package/dist/grid-messages.js +2 -0
  22. package/dist/index.d.ts +3 -4
  23. package/dist/index.js +2 -3
  24. package/dist/row-model.d.ts +133 -0
  25. package/dist/row-model.js +16 -0
  26. package/dist/selection.d.ts +2 -1
  27. package/dist/selection.js +30 -2
  28. package/dist/server-block-cache.d.ts +214 -0
  29. package/dist/server-block-cache.js +531 -0
  30. package/dist/server-data-source.d.ts +240 -6
  31. package/dist/server-data-source.js +220 -20
  32. package/dist/server.d.ts +13 -0
  33. package/dist/server.js +13 -0
  34. package/dist/validate.d.ts +2 -0
  35. package/dist/validate.js +8 -3
  36. package/package.json +6 -1
  37. package/src/SvGrid.controller.svelte.ts +190 -8
  38. package/src/SvGrid.css +73 -0
  39. package/src/SvGrid.svelte +82 -3
  40. package/src/SvGrid.types.ts +105 -3
  41. package/src/build-api.ts +11 -4
  42. package/src/core.rowmodel-cache.test.ts +51 -0
  43. package/src/core.ts +33 -12
  44. package/src/editing.ts +8 -0
  45. package/src/grid-icons.ts +2 -2
  46. package/src/grid-messages.ts +5 -0
  47. package/src/icon-seam.test.ts +2 -4
  48. package/src/index.ts +19 -10
  49. package/src/row-model.ts +146 -0
  50. package/src/selection.test.ts +3 -0
  51. package/src/selection.ts +32 -2
  52. package/src/server-block-cache.test.ts +645 -0
  53. package/src/server-block-cache.ts +677 -0
  54. package/src/server-data-source.infinite.test.ts +343 -0
  55. package/src/server-data-source.ts +450 -27
  56. package/src/server.ts +47 -0
  57. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  58. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  59. package/src/validate.test.ts +10 -0
  60. package/src/validate.ts +10 -3
  61. package/dist/SvGroupCell.svelte +0 -141
  62. package/dist/SvGroupCell.svelte.d.ts +0 -49
  63. package/dist/SvRowGroupPanel.svelte +0 -186
  64. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  65. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  66. package/dist/server-group-model.d.ts +0 -98
  67. package/dist/server-group-model.js +0 -263
  68. package/src/SvGroupCell.svelte +0 -141
  69. package/src/SvRowGroupPanel.svelte +0 -186
  70. package/src/server-group-model.test.ts +0 -294
  71. package/src/server-group-model.ts +0 -370
package/dist/server.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * `@svgrid/grid/server` - the server-side data primitives, with no Svelte in
3
+ * the import graph.
4
+ *
5
+ * Everything here is also on the package root. This entry point exists for
6
+ * code that must not pull `SvGrid.svelte` into its bundle or its test
7
+ * runner: a SvelteKit `+server.ts` shaping a `ServerRequest` into a query,
8
+ * a worker feeding a block cache, or `@svgrid/enterprise`'s own row model,
9
+ * whose unit tests run without the Svelte compiler.
10
+ */
11
+ export { createServerDataSource, } from './server-data-source.js';
12
+ export { createBlockCache, createRowPlaceholder, rowPlaceholderState, } from './server-block-cache.js';
13
+ export { toServerFilterColumns, } from './row-model.js';
@@ -35,6 +35,8 @@ export type ValidateInput<TFeatures extends TableFeatures, TData extends RowData
35
35
  idField?: string;
36
36
  column?: string;
37
37
  };
38
+ /** Server-side grouping: group rows carry their key and aggregates, not the leaf fields. */
39
+ serverGroup?: unknown;
38
40
  initialColumnPinning?: {
39
41
  left?: ReadonlyArray<string>;
40
42
  right?: ReadonlyArray<string>;
package/dist/validate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { rowPlaceholderState } from './server-block-cache.js';
1
2
  /** How many rows to sample when deciding whether a `field` exists. */
2
3
  const FIELD_SAMPLE_ROWS = 10;
3
4
  const DOCS = 'https://svgrid.com/docs/getting-started/3-data-and-columns/';
@@ -17,9 +18,13 @@ export function validateGridConfig(input) {
17
18
  // ---- 1. A `field` that does not exist on the data -------------------------
18
19
  // The single most expensive silent failure: the column renders, and every
19
20
  // cell in it is blank. Sampled across several rows so genuinely sparse data
20
- // (a key absent from row 0 but present later) does not trip it.
21
- const sample = (input.data ?? []).slice(0, FIELD_SAMPLE_ROWS);
22
- if (sample.length) {
21
+ // (a key absent from row 0 but present later) does not trip it. A row
22
+ // model shows placeholder rows before its first block lands; those carry
23
+ // no data keys at all and say nothing about the columns.
24
+ const sample = (input.data ?? []).filter((row) => !rowPlaceholderState(row)).slice(0, FIELD_SAMPLE_ROWS);
25
+ // Under server-side grouping the first rows are group rows, which carry
26
+ // a key and aggregates and none of the leaf fields; nothing to judge by.
27
+ if (sample.length && !input.serverGroup) {
23
28
  const known = new Set();
24
29
  for (const row of sample) {
25
30
  if (row && typeof row === 'object')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svgrid/grid",
3
- "version": "3.0.4",
3
+ "version": "4.0.0",
4
4
  "description": "Svelte 5-native data grid and data table. Headless-first engine with virtual scrolling for 100k+ rows, Excel-style filtering, inline editing, grouping, tree data, pivot, and server-side data. Drop-in <SvGrid> component. TypeScript, SSR-ready.",
5
5
  "author": "jQWidgets <boikom@jqwidgets.com>",
6
6
  "license": "MIT",
@@ -99,6 +99,11 @@
99
99
  "svelte": "./dist/shortcut-registry.js",
100
100
  "import": "./dist/shortcut-registry.js"
101
101
  },
102
+ "./server": {
103
+ "types": "./dist/server.d.ts",
104
+ "svelte": "./dist/server.js",
105
+ "import": "./dist/server.js"
106
+ },
102
107
  "./flex-render": {
103
108
  "types": "./dist/flex-render.d.ts",
104
109
  "svelte": "./dist/flex-render.js",
@@ -309,6 +309,60 @@ export function createSvGridController<
309
309
  // preserves reference identity for non-overridden keys, which the columns-identity
310
310
  // column cache and the `data`/`columns` sync effects rely on.
311
311
  let optionOverrides = $state<Record<string, unknown>>({});
312
+
313
+ /**
314
+ * A `rowModel` answers for the props it can, so a server-backed grid is
315
+ * one prop rather than a dozen.
316
+ *
317
+ * Bumped by the model itself through `subscribe` (see the effect below),
318
+ * which is what makes `getRows()` and `isLoading()` - plain method calls,
319
+ * invisible to the reactive system - re-read when the model changes.
320
+ */
321
+ let rowModelVersion = $state(0);
322
+ const rowModelProps = $derived.by(function rowModelProps_d() {
323
+ const model = rawProps.rowModel;
324
+ if (!model) return null;
325
+ rowModelVersion; // read, so a notification re-derives
326
+ const paging = model.pagination;
327
+ return {
328
+ data: model.getRows(),
329
+ loading: model.isLoading(),
330
+ loadingOverlay: true,
331
+ getRowId: model.getRowId,
332
+ // A model that sorts or filters server-side is telling the grid not
333
+ // to do it locally; one that does neither leaves the grid in charge.
334
+ externalSort: model.setSort ? true : undefined,
335
+ externalFilter: model.setFilter ? true : undefined,
336
+ onSortingChange: model.setSort ? (s: any) => model.setSort!(s) : undefined,
337
+ onFiltersChange: model.setFilter ? (f: any) => model.setFilter!(f) : undefined,
338
+ onVisibleRangeChange: model.setViewport
339
+ ? (r: { startIndex: number; endIndex: number }) =>
340
+ model.setViewport!(r.startIndex, r.endIndex)
341
+ : undefined,
342
+ rowPlaceholder: model.rowPlaceholder,
343
+ onRetryRow: model.retryRow,
344
+ serverGroup: model.group,
345
+ rowSelectionModel: model.selection,
346
+ serverFilterValues: model.filterValues
347
+ ? (columnId: string) => model.filterValues!(columnId)
348
+ : undefined,
349
+ pinnedTopRows: model.pinnedTopRows,
350
+ pinnedBottomRows: model.pinnedBottomRows,
351
+ pivotResultColumns: model.pivotResultColumns ?? undefined,
352
+ externalPagination: paging ? true : undefined,
353
+ rowCount: paging?.rowCount,
354
+ pageIndex: paging?.pageIndex,
355
+ pageSize: paging?.pageSize,
356
+ pageSizeOptions: paging?.pageSizes,
357
+ onPaginationChange: paging
358
+ ? (p: { pageIndex: number; pageSize: number }) => {
359
+ if (p.pageSize !== paging.pageSize) paging.setPageSize(p.pageSize);
360
+ else paging.setPage(p.pageIndex);
361
+ }
362
+ : undefined,
363
+ } as Partial<Props<TFeatures, TData>>;
364
+ });
365
+
312
366
  const props = new Proxy(rawProps, {
313
367
  // Read the override as a PROPERTY (tracked by `$state`), not via `in` (the `has`
314
368
  // trap is not tracked) - so a reactive read of `props.X` re-runs when an override
@@ -318,7 +372,21 @@ export function createSvGridController<
318
372
  get: (t, k) => {
319
373
  const v =
320
374
  typeof k === "string" ? optionOverrides[k] : undefined;
321
- return v !== undefined ? v : Reflect.get(t, k);
375
+ if (v !== undefined) return v;
376
+ // Pivot mode swaps the column list: `pivotResultColumns` (the prop, or
377
+ // the row model's) stands in for `columns` while it is set, and
378
+ // `columns` itself stays the app's own list for when pivot ends.
379
+ if (k === "columns") {
380
+ const pivot =
381
+ Reflect.get(t, "pivotResultColumns") ??
382
+ (rowModelProps as Record<string, unknown> | null)?.pivotResultColumns;
383
+ if (pivot) return pivot;
384
+ }
385
+ const own = Reflect.get(t, k);
386
+ // A prop written on the grid always beats the row model, so adopting
387
+ // `rowModel` is never all-or-nothing.
388
+ if (own !== undefined || typeof k !== "string") return own;
389
+ return (rowModelProps as Record<string, unknown> | null)?.[k];
322
390
  },
323
391
  }) as Props<TFeatures, TData>;
324
392
 
@@ -739,7 +807,10 @@ export function createSvGridController<
739
807
  // re-synced whenever the parent passes a new array; the imperative API
740
808
  // mutates these so add/remove operations don't need a callback round-trip.
741
809
  // svelte-ignore state_referenced_locally
742
- let internalData = $state.raw<ReadonlyArray<TData>>(props.data);
810
+ // `data` is optional on the type (a `rowModel` may supply the rows
811
+ // instead), so every read falls back to empty rather than to undefined.
812
+ const EMPTY_DATA: ReadonlyArray<TData> = [];
813
+ let internalData = $state.raw<ReadonlyArray<TData>>(props.data ?? EMPTY_DATA);
743
814
  // Resolve `cellDataType` / `inferColumnTypes` into concrete editorType +
744
815
  // format defaults once, up front, so every downstream reader sees a normal
745
816
  // column. Explicit fields on the ColumnDef always win.
@@ -791,7 +862,7 @@ export function createSvGridController<
791
862
  // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
792
863
  // would keep returning the old edited values from `editedCellValues`
793
864
  // even though the underlying data has been replaced.
794
- internalData = props.data;
865
+ internalData = props.data ?? EMPTY_DATA;
795
866
  editedCellValues = {};
796
867
  });
797
868
  $effect(() => {
@@ -1037,6 +1108,20 @@ export function createSvGridController<
1037
1108
  *
1038
1109
  * Returns null when the cell should render nothing.
1039
1110
  */
1111
+ /**
1112
+ * Why a row has no data yet, or null when it does.
1113
+ *
1114
+ * Group rows are never placeholders, so the check is skipped for them:
1115
+ * their `original` is a synthetic bucket, not a row the consumer handed
1116
+ * us, and passing it to `rowPlaceholder` would ask a question about a row
1117
+ * that does not exist.
1118
+ */
1119
+ function placeholderStateOf(row: Row<TData>): "loading" | "failed" | null {
1120
+ const ask = props.rowPlaceholder;
1121
+ if (!ask || isGroupRow(row)) return null;
1122
+ return ask(row.original, row.index);
1123
+ }
1124
+
1040
1125
  function autoGroupCell(
1041
1126
  row: Row<TData>,
1042
1127
  columnId: string,
@@ -1805,15 +1890,42 @@ export function createSvGridController<
1805
1890
  const rows: TData[] = [];
1806
1891
  const ids: string[] = [];
1807
1892
  if (!selectionBarOn) return { rows, ids };
1893
+ // Under an external selection model the loaded rows are the ones the
1894
+ // model says are selected; rows that never loaded are not in `allRows`
1895
+ // and are counted by `selectionBarCount` below instead. This walks
1896
+ // every loaded row, so it runs on every block a row model delivers:
1897
+ // a model that can say "nothing is selected" spares the walk, and the
1898
+ // props it needs are read once, not through the proxy per row.
1899
+ const model = props.rowSelectionModel;
1900
+ if (model && model.selectedCount?.() === 0) return { rows, ids };
1901
+ const ask = props.rowPlaceholder;
1902
+ const isPlaceholder = ask
1903
+ ? (row: Row<TData>) => !isGroupRow(row) && !!ask(row.original as TData, row.index)
1904
+ : () => false;
1808
1905
  for (const row of allRows) {
1809
- if (isGroupRow(row)) continue;
1810
- if (rowSelectionState[row.id]) {
1906
+ if (isGroupRow(row) || isPlaceholder(row)) continue;
1907
+ const selected = model
1908
+ ? model.isSelected(row.id, row.original as TData)
1909
+ : !!rowSelectionState[row.id];
1910
+ if (selected) {
1811
1911
  rows.push(row.original as TData);
1812
1912
  ids.push(row.id);
1813
1913
  }
1814
1914
  }
1815
1915
  return { rows, ids };
1816
1916
  });
1917
+ /**
1918
+ * What the selection bar's count chip says. A model that stores the rule
1919
+ * ("everything except these") knows about rows the grid never loaded, so
1920
+ * its number wins over counting ticks on screen; `null` from it means it
1921
+ * cannot say, and the bar falls back to the loaded count.
1922
+ */
1923
+ const selectionBarCount = $derived.by(function selectionBarCount_d() {
1924
+ const fromModel = props.rowSelectionModel?.selectedCount?.();
1925
+ return fromModel ?? selectionBarTarget.ids.length;
1926
+ });
1927
+ /** Whether the bar has anything to show: the model's count when it has one, else the loaded ticks. */
1928
+ const selectionBarVisible = $derived(selectionBarOn && selectionBarCount > 0);
1817
1929
 
1818
1930
  // Forward selection changes to the consumer. Skips the very first invocation
1819
1931
  // (the initial empty state) so consumers don't get a spurious callback on mount.
@@ -2833,6 +2945,55 @@ export function createSvGridController<
2833
2945
  Math.max(virtualRowTotalSize - virtualRowEnd, 0),
2834
2946
  );
2835
2947
 
2948
+ // A row model tells us when to re-read it. Subscribing here (rather than
2949
+ // polling, or asking the consumer to wrap the model in a rune) is what lets
2950
+ // `getRows()` be a plain method call on a plain object.
2951
+ $effect(() => {
2952
+ const model = rawProps.rowModel;
2953
+ if (!model) return;
2954
+ return model.subscribe(() => {
2955
+ rowModelVersion += 1;
2956
+ });
2957
+ });
2958
+
2959
+ // The index range on screen, for data sources that fetch by block.
2960
+ // `virtualRowStart` / `virtualRowEnd` above are PIXEL offsets; a fetcher
2961
+ // needs row numbers, and needs them whether or not virtualization is on.
2962
+ const visibleRowRange = $derived.by(function visibleRowRange_d() {
2963
+ if (!rowVirtualizationEnabled) {
2964
+ return { startIndex: 0, endIndex: Math.max(0, allRows.length - 1) };
2965
+ }
2966
+ const first = virtualRows[0];
2967
+ const last = virtualRows[virtualRows.length - 1];
2968
+ if (!first || !last) return { startIndex: 0, endIndex: 0 };
2969
+ return { startIndex: first.index, endIndex: last.index };
2970
+ });
2971
+ // Emitted from an effect rather than the derived so a consumer that
2972
+ // fetches (and therefore changes `data`) cannot re-enter the derivation.
2973
+ let lastReportedRange = "";
2974
+ $effect(() => {
2975
+ const report = props.onVisibleRangeChange;
2976
+ if (!report) return;
2977
+ const range = visibleRowRange;
2978
+ const key = range.startIndex + ":" + range.endIndex;
2979
+ if (key === lastReportedRange) return;
2980
+ lastReportedRange = key;
2981
+ report(range);
2982
+ });
2983
+
2984
+ // A paging row model that asks for `autoPageSize` gets as many rows as
2985
+ // the body shows without scrolling, re-measured as the viewport changes.
2986
+ $effect(() => {
2987
+ rowModelVersion;
2988
+ const paging = rawProps.rowModel?.pagination;
2989
+ if (!paging?.autoPageSize) return;
2990
+ const height = viewportHeight;
2991
+ if (height <= 0) return;
2992
+ const rowPx = rowSizePxOf(0) || 30;
2993
+ const fit = Math.max(1, Math.floor(height / rowPx));
2994
+ if (fit !== paging.pageSize) paging.setPageSize(fit);
2995
+ });
2996
+
2836
2997
  // --- Huge-list scroll scaling -----------------------------------------
2837
2998
  // Browsers cap how tall a single element may be, and mobile caps sit well
2838
2999
  // below desktop. Past a few hundred thousand rows the true content height
@@ -3596,7 +3757,13 @@ export function createSvGridController<
3596
3757
  // rows (10k iterations on the large-dataset demo, 100k on its top tier) to
3597
3758
  // recount a selection that had not changed.
3598
3759
  const headerSelectionState = $derived.by(function headerSelectionState_d() {
3599
- const selectable = allRows.filter((row) => !isGroupRow(row));
3760
+ // An external model knows about rows the grid has never seen, so it is
3761
+ // the only thing that can answer honestly once the data is a window
3762
+ // onto a server.
3763
+ if (props.rowSelectionModel) return props.rowSelectionModel.headerState();
3764
+ const selectable = allRows.filter(
3765
+ (row) => !isGroupRow(row) && !placeholderStateOf(row),
3766
+ );
3600
3767
  if (!selectable.length) return "none";
3601
3768
  let selected = 0;
3602
3769
  for (const row of selectable) if (rowSelectionState[row.id]) selected += 1;
@@ -3820,7 +3987,12 @@ export function createSvGridController<
3820
3987
  for (const column of allColumns) {
3821
3988
  const meta = isBucketableColumn(column);
3822
3989
  if (!meta) continue;
3823
- const buckets = buildBuckets(column, meta.isDate, props.data, getColumnAccessorValue);
3990
+ const buckets = buildBuckets(
3991
+ column,
3992
+ meta.isDate,
3993
+ props.data ?? EMPTY_DATA,
3994
+ getColumnAccessorValue,
3995
+ );
3824
3996
  if (buckets) map.set(column.id, buckets);
3825
3997
  }
3826
3998
  return map;
@@ -4329,6 +4501,8 @@ export function createSvGridController<
4329
4501
  get selectionBarMaxVisible() { return selectionBarMaxVisible; },
4330
4502
  get selectionBarHideClear() { return selectionBarHideClear; },
4331
4503
  get selectionBarTarget() { return selectionBarTarget; },
4504
+ get selectionBarCount() { return selectionBarCount; },
4505
+ get selectionBarVisible() { return selectionBarVisible; },
4332
4506
  get lastSelectionSerialized() { return lastSelectionSerialized; },
4333
4507
  set lastSelectionSerialized(v) { lastSelectionSerialized = v as never; },
4334
4508
  get lastCellRangeSerialized() { return lastCellRangeSerialized; },
@@ -4664,7 +4838,14 @@ export function createSvGridController<
4664
4838
  get isRowSelected() { return isRowSelected; },
4665
4839
  get toggleRowSelectionById() { return toggleRowSelectionById; },
4666
4840
  get headerSelectionState() { return headerSelectionState; },
4841
+ get visibleRowRange() { return visibleRowRange; },
4842
+ placeholderStateOf,
4843
+ /** Retry a failed placeholder row. No-op without `onRetryRow`. */
4844
+ retryRow(row: Row<TData>) {
4845
+ props.onRetryRow?.(row.original, row.index);
4846
+ },
4667
4847
  get toggleSelectAllRows() { return toggleSelectAllRows; },
4848
+ get setSelectAllRows() { return setSelectAllRows; },
4668
4849
  get userHasActivatedCell() { return userHasActivatedCell; },
4669
4850
  set userHasActivatedCell(v) { userHasActivatedCell = v as never; },
4670
4851
  get tabRunOrigin() { return tabRunOrigin; },
@@ -4814,7 +4995,7 @@ export function createSvGridController<
4814
4995
  const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, setFacetSelection, setFilterTokens, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
4815
4996
  const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, areEditorOptionsLoading, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
4816
4997
  const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste, armPasteFallback } = createEditing<TFeatures, TData>(ctx);
4817
- const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, activeRangeRect, cellSelectionState, getSelectionRects, isInFillPreview, dragPreviewRect, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
4998
+ const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, activeRangeRect, cellSelectionState, getSelectionRects, isInFillPreview, dragPreviewRect, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
4818
4999
  const { cellPinStyle, isColumnPinned, getCurrentColumnOrder, emitColumnOrder, setColumnOrderInternal, applyColumnDrop, onColumnHeaderDragStart, onColumnHeaderDragOver, onColumnHeaderDragLeave, onColumnHeaderDrop, onColumnHeaderDragEnd, pinColumnLeft, pinColumnRight, unpinColumn, toggleColumnVisibleInPanel, moveColumnInPanel, toggleGroupInPanel, getColumnBaseWidth, getColumnWidth, measureText, autosizeColumn, autosizeAllColumns, resetColumns } = createColumns<TFeatures, TData>(ctx);
4819
5000
  const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd, onRowPointerDown, destroyRowDrag } = createRowDrag<TFeatures, TData>(ctx);
4820
5001
  const { register: registerAlignedGrid, broadcastScroll: broadcastAlignedScroll, broadcastWidths: broadcastAlignedWidths } = createAlignedGrids<TFeatures, TData>(ctx);
@@ -4854,6 +5035,7 @@ export function createSvGridController<
4854
5035
  pageSize: props.pageSize,
4855
5036
  groupBy: props.groupBy,
4856
5037
  treeData: props.treeData,
5038
+ serverGroup: props.serverGroup,
4857
5039
  initialColumnPinning: props.initialColumnPinning,
4858
5040
  columnVirtualization: props.columnVirtualization,
4859
5041
  externalPagination: props.externalPagination,
package/src/SvGrid.css CHANGED
@@ -463,6 +463,79 @@
463
463
  user-select: text;
464
464
  }
465
465
 
466
+ /* Rows whose data has not arrived yet (server-side / infinite loading).
467
+
468
+ The shimmer is per CELL, not per row, so the columns keep their widths
469
+ and the table does not reflow as blocks land. Widths vary a little by
470
+ column position so a screenful of them reads as text rather than as a
471
+ barcode - the same trick every skeleton screen uses. */
472
+ .sv-grid-placeholder-cell {
473
+ user-select: none;
474
+ cursor: default;
475
+ }
476
+ /* The per-cell shimmer of a placeholder row. Its own name: `.sv-grid-skeleton`
477
+ above is the full-body loading overlay, absolutely positioned under the
478
+ header, and sharing it pulled every placeholder cell's bar up there. */
479
+ .sv-grid-placeholder-skeleton {
480
+ display: inline-block;
481
+ width: 72%;
482
+ height: 9px;
483
+ border-radius: 5px;
484
+ vertical-align: middle;
485
+ background: linear-gradient(
486
+ 90deg,
487
+ color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent) 25%,
488
+ color-mix(in srgb, var(--sg-fg, #0f172a) 3%, transparent) 50%,
489
+ color-mix(in srgb, var(--sg-fg, #0f172a) 8%, transparent) 75%
490
+ );
491
+ background-size: 200% 100%;
492
+ animation: sv-grid-skeleton-shimmer 1.2s linear infinite;
493
+ }
494
+ .sv-grid-placeholder-cell:nth-child(2n) .sv-grid-placeholder-skeleton { width: 54%; }
495
+ .sv-grid-placeholder-cell:nth-child(3n) .sv-grid-placeholder-skeleton { width: 84%; }
496
+ @keyframes sv-grid-skeleton-shimmer {
497
+ from { background-position: 200% 0; }
498
+ to { background-position: -200% 0; }
499
+ }
500
+ @media (prefers-reduced-motion: reduce) {
501
+ .sv-grid-placeholder-skeleton { animation: none; }
502
+ }
503
+
504
+ /* A block that failed to load: one message across the row, and a way out.
505
+ The cell stays a table cell so its colspan holds; the flex box is inside
506
+ it (a flex `td` stops spanning and the tint shrank to the text). Rows
507
+ that continue a failed run are the same band without the message. */
508
+ .sv-grid-placeholder-failed .sv-grid-placeholder-cell {
509
+ background: color-mix(in srgb, var(--sg-danger, #dc2626) 6%, transparent);
510
+ }
511
+ .sv-grid-placeholder-failed-body {
512
+ display: flex;
513
+ align-items: center;
514
+ gap: 10px;
515
+ min-height: 100%;
516
+ padding-left: var(--sg-cell-px, 7px);
517
+ }
518
+ .sv-grid-placeholder-failed-cont .sv-grid-placeholder-cell {
519
+ border-top-color: transparent;
520
+ }
521
+ .sv-grid-placeholder-error {
522
+ color: var(--sg-danger, #dc2626);
523
+ font-size: 13px;
524
+ }
525
+ .sv-grid-placeholder-retry {
526
+ background: none;
527
+ border: 1px solid var(--sg-border, #e2e8f0);
528
+ border-radius: var(--sg-radius, 6px);
529
+ padding: 2px 10px;
530
+ font: inherit;
531
+ font-size: 12px;
532
+ color: var(--sg-fg, #0f172a);
533
+ cursor: pointer;
534
+ }
535
+ .sv-grid-placeholder-retry:hover {
536
+ background: color-mix(in srgb, var(--sg-fg, #0f172a) 6%, transparent);
537
+ }
538
+
466
539
  /* Pinned columns read as "frozen" via three layered cues:
467
540
  1. A distinct fill (slightly stronger than the header) so the
468
541
  pinned strip is visible even before you scroll. Themes with a
package/src/SvGrid.svelte CHANGED
@@ -408,6 +408,11 @@
408
408
  ctrl.columnVirtualizationEnabled,
409
409
  );
410
410
  const virtualRows = $derived(ctrl.virtualRows);
411
+ // The first body row on screen: where a failed run that started above
412
+ // the window repeats its message.
413
+ const firstRenderedRowIndex = $derived(
414
+ rowVirtualizationEnabled ? (virtualRows[0]?.index ?? 0) : 0,
415
+ );
411
416
  const frozenRowCount = $derived(ctrl.frozenRowCount);
412
417
  const frozenRowList = $derived(ctrl.frozenRowList);
413
418
  // Merged cells. A merge is drawn once per band (the frozen rows, the
@@ -1461,6 +1466,73 @@
1461
1466
  </tr>
1462
1467
  {/snippet}
1463
1468
 
1469
+ <!-- A row whose data has not arrived.
1470
+
1471
+ "loading" keeps the real columns and draws a shimmer bar in each,
1472
+ so the table keeps its shape while blocks stream in - a full-width
1473
+ bar would make the whole grid jump every time one landed. "failed"
1474
+ is the opposite case: there is nothing per-column to say, just one
1475
+ message and a way to try again, so it spans the row like a detail
1476
+ cell does. Neither is selectable or editable; there is nothing
1477
+ there to act on.
1478
+
1479
+ A failed block is a run of failed rows, one per row it claimed,
1480
+ and one message serves the run: the first failed row on screen
1481
+ carries it (the run's first row, or the top of the window when
1482
+ the run started above it) and the rows under it are a plain
1483
+ tinted band. A hundred "Could not load" lines for one block
1484
+ was a wall of red. -->
1485
+ {#snippet placeholderRowMarkup(
1486
+ phRow: Row<TData>,
1487
+ phRowIndex: number,
1488
+ phState: "loading" | "failed",
1489
+ phStyle: string,
1490
+ phContinues: boolean,
1491
+ )}
1492
+ <tr
1493
+ class="sv-grid-row sv-grid-placeholder-row"
1494
+ class:sv-grid-placeholder-failed={phState === "failed"}
1495
+ class:sv-grid-placeholder-failed-cont={phContinues}
1496
+ aria-busy={phState === "loading" ? "true" : undefined}
1497
+ {...getGridRowA11yProps(phRowIndex + 1)}
1498
+ style={phStyle}
1499
+ >
1500
+ {#if phState === "failed"}
1501
+ <td
1502
+ class="sv-grid-cell sv-grid-placeholder-cell"
1503
+ colspan={allColumns.length +
1504
+ (showRowNumbersEffective ? 1 : 0) +
1505
+ (showRowSelectionEffective ? 1 : 0)}
1506
+ >
1507
+ {#if !phContinues}
1508
+ <div class="sv-grid-placeholder-failed-body">
1509
+ <span class="sv-grid-placeholder-error">{messages.rowLoadFailed}</span>
1510
+ {#if opt.onRetryRow}
1511
+ <button
1512
+ type="button"
1513
+ class="sv-grid-placeholder-retry"
1514
+ onclick={() => ctrl.retryRow(phRow)}>{messages.rowLoadRetry}</button
1515
+ >
1516
+ {/if}
1517
+ </div>
1518
+ {/if}
1519
+ </td>
1520
+ {:else}
1521
+ {#if showRowNumbersEffective}
1522
+ <td class="sv-grid-cell sv-grid-rownum-cell"></td>
1523
+ {/if}
1524
+ {#if showRowSelectionEffective}
1525
+ <td class="sv-grid-cell sv-grid-select-cell"></td>
1526
+ {/if}
1527
+ {#each allColumns as col (col.id)}
1528
+ <td class="sv-grid-cell sv-grid-placeholder-cell">
1529
+ <span class="sv-grid-placeholder-skeleton" aria-hidden="true"></span>
1530
+ </td>
1531
+ {/each}
1532
+ {/if}
1533
+ </tr>
1534
+ {/snippet}
1535
+
1464
1536
  <!-- A single pinned row (top or bottom). Read-only by design: no
1465
1537
  inline editing, no row-selection checkbox, no fill handle.
1466
1538
  Position-sticky CSS keeps it anchored to the top of the body or
@@ -1474,7 +1546,14 @@
1474
1546
  the virtualizer's measured size, or the declared one.
1475
1547
  -->
1476
1548
  {#snippet bodyRow(row: Row<TData>, rowIndex: number, rowStyle: string)}
1477
- {#if opt.isDetailRow?.(row.original as TData, rowIndex)}
1549
+ {@const phState = ctrl.placeholderStateOf(row)}
1550
+ {#if phState}
1551
+ {@const phContinues =
1552
+ phState === "failed" &&
1553
+ rowIndex > firstRenderedRowIndex &&
1554
+ ctrl.placeholderStateOf(allRows[rowIndex - 1]!) === "failed"}
1555
+ {@render placeholderRowMarkup(row, rowIndex, phState, rowStyle, phContinues)}
1556
+ {:else if opt.isDetailRow?.(row.original as TData, rowIndex)}
1478
1557
  {@render detailRowMarkup(row, rowIndex)}
1479
1558
  {:else if isGroupRow(row) && !groupColumnMode}
1480
1559
  <tr
@@ -1827,7 +1906,7 @@
1827
1906
  style={chartDockReserveStyle}
1828
1907
  data-move-grab={ctrl.moveGrabHover || ctrl.moveDrag ? "true" : undefined}
1829
1908
  data-selbar={
1830
- ctrl.selectionBarOn && ctrl.selectionBarTarget.ids.length > 0
1909
+ ctrl.selectionBarVisible
1831
1910
  ? (getSelectionBarView() ? ctrl.selectionBarPosition : `upsell-${ctrl.selectionBarPosition}`)
1832
1911
  : undefined
1833
1912
  }
@@ -3004,7 +3083,7 @@
3004
3083
  `absolute` against it, so moving it outside would anchor it to the
3005
3084
  page. The renderer is Enterprise and arrives through the
3006
3085
  selection-bar-view seam; without it, a short note in its place. -->
3007
- {#if ctrl.selectionBarOn && ctrl.selectionBarTarget.ids.length > 0}
3086
+ {#if ctrl.selectionBarVisible}
3008
3087
  {#if getSelectionBarView()}
3009
3088
  {@const SelectionBarView = getSelectionBarView()}
3010
3089
  <SelectionBarView {ctrl} />