@svgrid/grid 3.0.4 → 3.0.5

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 (77) hide show
  1. package/README.md +4 -4
  2. package/dist/SvGrid.controller.svelte.d.ts +12 -2
  3. package/dist/SvGrid.controller.svelte.js +192 -8
  4. package/dist/SvGrid.css +73 -0
  5. package/dist/SvGrid.svelte +128 -3
  6. package/dist/SvGrid.types.d.ts +356 -3
  7. package/dist/build-api.js +11 -6
  8. package/dist/cdn/{GridMenus-BL7ZgQvU.js → GridMenus-CID62rlI.js} +11 -11
  9. package/dist/cdn/{GridMenus-7kbpnnBW.js → GridMenus-DLL-qZeu.js} +11 -11
  10. package/dist/cdn/server-block-cache-CGoWz-87.js +237 -0
  11. package/dist/cdn/{src-BKhZ6eXd.js → src-Bklofsad.js} +8818 -8798
  12. package/dist/cdn/{src-V1uu8iE9.js → src-jcFMkBnN.js} +4282 -4262
  13. package/dist/cdn/svgrid.js +27 -26
  14. package/dist/cdn/svgrid.svelte-external.js +27 -26
  15. package/dist/cdn/validate-C7Rn_Bns.js +76 -0
  16. package/dist/core.js +36 -13
  17. package/dist/editing.js +10 -0
  18. package/dist/gantt-view.svelte.d.ts +24 -0
  19. package/dist/gantt-view.svelte.js +13 -0
  20. package/dist/grid-icons.d.ts +2 -2
  21. package/dist/grid-icons.js +2 -2
  22. package/dist/grid-messages.d.ts +6 -0
  23. package/dist/grid-messages.js +5 -0
  24. package/dist/index.d.ts +5 -4
  25. package/dist/index.js +3 -3
  26. package/dist/row-model.d.ts +133 -0
  27. package/dist/row-model.js +16 -0
  28. package/dist/selection.d.ts +2 -1
  29. package/dist/selection.js +30 -2
  30. package/dist/server-block-cache.d.ts +214 -0
  31. package/dist/server-block-cache.js +531 -0
  32. package/dist/server-data-source.d.ts +240 -6
  33. package/dist/server-data-source.js +220 -20
  34. package/dist/server.d.ts +13 -0
  35. package/dist/server.js +13 -0
  36. package/dist/validate.d.ts +4 -0
  37. package/dist/validate.js +21 -3
  38. package/package.json +6 -1
  39. package/src/SvGrid.controller.svelte.ts +191 -8
  40. package/src/SvGrid.css +73 -0
  41. package/src/SvGrid.svelte +128 -3
  42. package/src/SvGrid.types.ts +376 -3
  43. package/src/build-api.ts +11 -4
  44. package/src/core.rowmodel-cache.test.ts +51 -0
  45. package/src/core.ts +33 -12
  46. package/src/editing.ts +8 -0
  47. package/src/gantt-stub.test.svelte +38 -0
  48. package/src/gantt-view.svelte.ts +35 -0
  49. package/src/grid-icons.ts +2 -2
  50. package/src/grid-messages.ts +12 -0
  51. package/src/icon-seam.test.ts +2 -4
  52. package/src/index.ts +36 -10
  53. package/src/row-model.ts +146 -0
  54. package/src/selection.test.ts +3 -0
  55. package/src/selection.ts +32 -2
  56. package/src/server-block-cache.test.ts +645 -0
  57. package/src/server-block-cache.ts +677 -0
  58. package/src/server-data-source.infinite.test.ts +343 -0
  59. package/src/server-data-source.ts +450 -27
  60. package/src/server.ts +47 -0
  61. package/src/svgrid.gantt-seam.test.ts +199 -0
  62. package/src/svgrid.row-model-prop.svelte.test.ts +352 -0
  63. package/src/svgrid.row-model-seam.svelte.test.ts +289 -0
  64. package/src/svgrid.upsell-license.test.ts +7 -4
  65. package/src/validate.test.ts +29 -0
  66. package/src/validate.ts +28 -3
  67. package/dist/SvGroupCell.svelte +0 -141
  68. package/dist/SvGroupCell.svelte.d.ts +0 -49
  69. package/dist/SvRowGroupPanel.svelte +0 -186
  70. package/dist/SvRowGroupPanel.svelte.d.ts +0 -25
  71. package/dist/cdn/validate-_CDJzgIo.js +0 -75
  72. package/dist/server-group-model.d.ts +0 -98
  73. package/dist/server-group-model.js +0 -263
  74. package/src/SvGroupCell.svelte +0 -141
  75. package/src/SvRowGroupPanel.svelte +0 -186
  76. package/src/server-group-model.test.ts +0 -294
  77. package/src/server-group-model.ts +0 -370
package/README.md CHANGED
@@ -92,7 +92,7 @@ all wire up the moment you turn on the matching prop.
92
92
  - **Selection** - cell-range click+drag and Shift+arrows, copy/paste as TSV, Excel-style fill handle, row selection.
93
93
  - **Views** - row grouping with aggregation, tree data, master/detail, full-width detail rows, spreadsheet mode with formulas.
94
94
  - **Layout** - row + column pinning, sticky header + first column, header drag-to-reorder, autosize, keyboard-accessible column sizing, zebra rows, responsive mode.
95
- - **Data operations** - find in grid (Ctrl+F), undo / redo, transaction API, optimistic updates, server-side row model with sort / filter / group pushdown.
95
+ - **Data operations** - find in grid (Ctrl+F), undo / redo, transaction API, optimistic updates, server-side data with sort / filter / paging / infinite-scroll pushdown (lazy grouping, tree, pivot and transactions on the server are in `@svgrid/enterprise`).
96
96
  - **AI helpers, free** - natural-language filter, smart fill, summarize, classify, anomaly detection, and "chart this". Model-agnostic: register one provider, nothing is bundled.
97
97
  - **UI components** - a Svelte 5 component suite ships in the package (inputs, selection, date/time, overlays, layout, feedback), standalone or as grid cell editors.
98
98
  - **Accessibility** - WAI-ARIA grid roles, full keyboard navigation, RTL, high-contrast theme.
@@ -154,14 +154,14 @@ See [Use sv-grid docs as LLM context](https://svgrid.com/docs/help/llm-grounding
154
154
  ## Enterprise features
155
155
 
156
156
  The MIT core is free for any use, including commercial, with **no feature gating**. Sorting, filtering,
157
- editing, virtualization, the **server-side row model**, and the **AI helpers** all ship in the free
158
- package. The optional [`@svgrid/enterprise`](https://www.npmjs.com/package/@svgrid/enterprise) pack layers
157
+ editing, virtualization, **server-side data** (paging, infinite block scroll, sort, filter, CRUD) and the
158
+ **AI helpers** all ship in the free package. The optional [`@svgrid/enterprise`](https://www.npmjs.com/package/@svgrid/enterprise) pack layers
159
159
  on the export- and analytics-heavy features teams tend to need last:
160
160
 
161
161
  - **Excel / PDF / CSV / TSV / HTML export** plus paginated print.
162
162
  - **Excel / CSV / TSV / JSON import** with auto-mapping and per-row validation.
163
163
  - **Pivot tables** with a drag-and-drop pivot Designer.
164
- - **Kanban board and scheduler / calendar renderers** for the `board` and `scheduler` props.
164
+ - **Kanban board, scheduler / calendar and Gantt renderers** for the `board`, `scheduler` and `gantt` props.
165
165
 
166
166
  OSS projects receive an Enterprise key free. See [Pricing](https://svgrid.com/pricing/).
167
167
 
@@ -297,6 +297,8 @@ export declare function createSvGridController<TFeatures extends TableFeatures =
297
297
  rows: TData[];
298
298
  ids: string[];
299
299
  };
300
+ readonly selectionBarCount: number;
301
+ readonly selectionBarVisible: boolean;
300
302
  lastSelectionSerialized: string;
301
303
  lastCellRangeSerialized: string;
302
304
  readonly statusBarEnabled: boolean;
@@ -598,10 +600,18 @@ export declare function createSvGridController<TFeatures extends TableFeatures =
598
600
  readonly getPinnedCellValue: typeof getPinnedCellValue;
599
601
  readonly formatPinnedValue: (column: Column<TData>, value: unknown) => string;
600
602
  readonly computePinnedCellClass: (rowData: TData, column: Column<TData>) => string;
601
- readonly isRowSelected: (rowId: string) => boolean;
603
+ readonly isRowSelected: (rowId: string) => any;
602
604
  readonly toggleRowSelectionById: (rowId: string) => void;
603
- readonly headerSelectionState: "none" | "all" | "some";
605
+ readonly headerSelectionState: "some" | "none" | "all";
606
+ readonly visibleRowRange: {
607
+ startIndex: number;
608
+ endIndex: number;
609
+ };
610
+ placeholderStateOf: (row: Row<TData>) => "loading" | "failed" | null;
611
+ /** Retry a failed placeholder row. No-op without `onRetryRow`. */
612
+ retryRow(row: Row<TData>): void;
604
613
  readonly toggleSelectAllRows: () => void;
614
+ readonly setSelectAllRows: (select: boolean) => void;
605
615
  userHasActivatedCell: boolean;
606
616
  tabRunOrigin: number | null;
607
617
  readonly setActiveCell: (rowIndex: number, colIndex: number) => void;
@@ -171,6 +171,60 @@ export function createSvGridController(rawProps, domIdBase) {
171
171
  // preserves reference identity for non-overridden keys, which the columns-identity
172
172
  // column cache and the `data`/`columns` sync effects rely on.
173
173
  let optionOverrides = $state({});
174
+ /**
175
+ * A `rowModel` answers for the props it can, so a server-backed grid is
176
+ * one prop rather than a dozen.
177
+ *
178
+ * Bumped by the model itself through `subscribe` (see the effect below),
179
+ * which is what makes `getRows()` and `isLoading()` - plain method calls,
180
+ * invisible to the reactive system - re-read when the model changes.
181
+ */
182
+ let rowModelVersion = $state(0);
183
+ const rowModelProps = $derived.by(function rowModelProps_d() {
184
+ const model = rawProps.rowModel;
185
+ if (!model)
186
+ return null;
187
+ rowModelVersion; // read, so a notification re-derives
188
+ const paging = model.pagination;
189
+ return {
190
+ data: model.getRows(),
191
+ loading: model.isLoading(),
192
+ loadingOverlay: true,
193
+ getRowId: model.getRowId,
194
+ // A model that sorts or filters server-side is telling the grid not
195
+ // to do it locally; one that does neither leaves the grid in charge.
196
+ externalSort: model.setSort ? true : undefined,
197
+ externalFilter: model.setFilter ? true : undefined,
198
+ onSortingChange: model.setSort ? (s) => model.setSort(s) : undefined,
199
+ onFiltersChange: model.setFilter ? (f) => model.setFilter(f) : undefined,
200
+ onVisibleRangeChange: model.setViewport
201
+ ? (r) => model.setViewport(r.startIndex, r.endIndex)
202
+ : undefined,
203
+ rowPlaceholder: model.rowPlaceholder,
204
+ onRetryRow: model.retryRow,
205
+ serverGroup: model.group,
206
+ rowSelectionModel: model.selection,
207
+ serverFilterValues: model.filterValues
208
+ ? (columnId) => model.filterValues(columnId)
209
+ : undefined,
210
+ pinnedTopRows: model.pinnedTopRows,
211
+ pinnedBottomRows: model.pinnedBottomRows,
212
+ pivotResultColumns: model.pivotResultColumns ?? undefined,
213
+ externalPagination: paging ? true : undefined,
214
+ rowCount: paging?.rowCount,
215
+ pageIndex: paging?.pageIndex,
216
+ pageSize: paging?.pageSize,
217
+ pageSizeOptions: paging?.pageSizes,
218
+ onPaginationChange: paging
219
+ ? (p) => {
220
+ if (p.pageSize !== paging.pageSize)
221
+ paging.setPageSize(p.pageSize);
222
+ else
223
+ paging.setPage(p.pageIndex);
224
+ }
225
+ : undefined,
226
+ };
227
+ });
174
228
  const props = new Proxy(rawProps, {
175
229
  // Read the override as a PROPERTY (tracked by `$state`), not via `in` (the `has`
176
230
  // trap is not tracked) - so a reactive read of `props.X` re-runs when an override
@@ -179,7 +233,23 @@ export function createSvGridController(rawProps, domIdBase) {
179
233
  // undefined too. Whole-object reassignment on write makes the signal fire.
180
234
  get: (t, k) => {
181
235
  const v = typeof k === "string" ? optionOverrides[k] : undefined;
182
- return v !== undefined ? v : Reflect.get(t, k);
236
+ if (v !== undefined)
237
+ return v;
238
+ // Pivot mode swaps the column list: `pivotResultColumns` (the prop, or
239
+ // the row model's) stands in for `columns` while it is set, and
240
+ // `columns` itself stays the app's own list for when pivot ends.
241
+ if (k === "columns") {
242
+ const pivot = Reflect.get(t, "pivotResultColumns") ??
243
+ rowModelProps?.pivotResultColumns;
244
+ if (pivot)
245
+ return pivot;
246
+ }
247
+ const own = Reflect.get(t, k);
248
+ // A prop written on the grid always beats the row model, so adopting
249
+ // `rowModel` is never all-or-nothing.
250
+ if (own !== undefined || typeof k !== "string")
251
+ return own;
252
+ return rowModelProps?.[k];
183
253
  },
184
254
  });
185
255
  // Resolved capability gates. Capabilities are OFF by default - a bare
@@ -518,7 +588,10 @@ export function createSvGridController(rawProps, domIdBase) {
518
588
  // re-synced whenever the parent passes a new array; the imperative API
519
589
  // mutates these so add/remove operations don't need a callback round-trip.
520
590
  // svelte-ignore state_referenced_locally
521
- let internalData = $state.raw(props.data);
591
+ // `data` is optional on the type (a `rowModel` may supply the rows
592
+ // instead), so every read falls back to empty rather than to undefined.
593
+ const EMPTY_DATA = [];
594
+ let internalData = $state.raw(props.data ?? EMPTY_DATA);
522
595
  // Resolve `cellDataType` / `inferColumnTypes` into concrete editorType +
523
596
  // format defaults once, up front, so every downstream reader sees a normal
524
597
  // column. Explicit fields on the ColumnDef always win.
@@ -563,7 +636,7 @@ export function createSvGridController(rawProps, domIdBase) {
563
636
  // accumulated cell-edit overrides - otherwise `getCellDisplayValue`
564
637
  // would keep returning the old edited values from `editedCellValues`
565
638
  // even though the underlying data has been replaced.
566
- internalData = props.data;
639
+ internalData = props.data ?? EMPTY_DATA;
567
640
  editedCellValues = {};
568
641
  });
569
642
  $effect(() => {
@@ -796,6 +869,20 @@ export function createSvGridController(rawProps, domIdBase) {
796
869
  *
797
870
  * Returns null when the cell should render nothing.
798
871
  */
872
+ /**
873
+ * Why a row has no data yet, or null when it does.
874
+ *
875
+ * Group rows are never placeholders, so the check is skipped for them:
876
+ * their `original` is a synthetic bucket, not a row the consumer handed
877
+ * us, and passing it to `rowPlaceholder` would ask a question about a row
878
+ * that does not exist.
879
+ */
880
+ function placeholderStateOf(row) {
881
+ const ask = props.rowPlaceholder;
882
+ if (!ask || isGroupRow(row))
883
+ return null;
884
+ return ask(row.original, row.index);
885
+ }
799
886
  function autoGroupCell(row, columnId) {
800
887
  if (!isGroupRow(row))
801
888
  return null;
@@ -1458,16 +1545,44 @@ export function createSvGridController(rawProps, domIdBase) {
1458
1545
  const ids = [];
1459
1546
  if (!selectionBarOn)
1460
1547
  return { rows, ids };
1548
+ // Under an external selection model the loaded rows are the ones the
1549
+ // model says are selected; rows that never loaded are not in `allRows`
1550
+ // and are counted by `selectionBarCount` below instead. This walks
1551
+ // every loaded row, so it runs on every block a row model delivers:
1552
+ // a model that can say "nothing is selected" spares the walk, and the
1553
+ // props it needs are read once, not through the proxy per row.
1554
+ const model = props.rowSelectionModel;
1555
+ if (model && model.selectedCount?.() === 0)
1556
+ return { rows, ids };
1557
+ const ask = props.rowPlaceholder;
1558
+ const isPlaceholder = ask
1559
+ ? (row) => !isGroupRow(row) && !!ask(row.original, row.index)
1560
+ : () => false;
1461
1561
  for (const row of allRows) {
1462
- if (isGroupRow(row))
1562
+ if (isGroupRow(row) || isPlaceholder(row))
1463
1563
  continue;
1464
- if (rowSelectionState[row.id]) {
1564
+ const selected = model
1565
+ ? model.isSelected(row.id, row.original)
1566
+ : !!rowSelectionState[row.id];
1567
+ if (selected) {
1465
1568
  rows.push(row.original);
1466
1569
  ids.push(row.id);
1467
1570
  }
1468
1571
  }
1469
1572
  return { rows, ids };
1470
1573
  });
1574
+ /**
1575
+ * What the selection bar's count chip says. A model that stores the rule
1576
+ * ("everything except these") knows about rows the grid never loaded, so
1577
+ * its number wins over counting ticks on screen; `null` from it means it
1578
+ * cannot say, and the bar falls back to the loaded count.
1579
+ */
1580
+ const selectionBarCount = $derived.by(function selectionBarCount_d() {
1581
+ const fromModel = props.rowSelectionModel?.selectedCount?.();
1582
+ return fromModel ?? selectionBarTarget.ids.length;
1583
+ });
1584
+ /** Whether the bar has anything to show: the model's count when it has one, else the loaded ticks. */
1585
+ const selectionBarVisible = $derived(selectionBarOn && selectionBarCount > 0);
1471
1586
  // Forward selection changes to the consumer. Skips the very first invocation
1472
1587
  // (the initial empty state) so consumers don't get a spurious callback on mount.
1473
1588
  let lastSelectionSerialized = "";
@@ -2419,6 +2534,59 @@ export function createSvGridController(rawProps, domIdBase) {
2419
2534
  const virtualRowStart = $derived.by(() => virtualRows[0]?.start ?? 0);
2420
2535
  const virtualRowEnd = $derived.by(() => virtualRows[virtualRows.length - 1]?.end ?? 0);
2421
2536
  const virtualRowBottomSpacer = $derived.by(() => Math.max(virtualRowTotalSize - virtualRowEnd, 0));
2537
+ // A row model tells us when to re-read it. Subscribing here (rather than
2538
+ // polling, or asking the consumer to wrap the model in a rune) is what lets
2539
+ // `getRows()` be a plain method call on a plain object.
2540
+ $effect(() => {
2541
+ const model = rawProps.rowModel;
2542
+ if (!model)
2543
+ return;
2544
+ return model.subscribe(() => {
2545
+ rowModelVersion += 1;
2546
+ });
2547
+ });
2548
+ // The index range on screen, for data sources that fetch by block.
2549
+ // `virtualRowStart` / `virtualRowEnd` above are PIXEL offsets; a fetcher
2550
+ // needs row numbers, and needs them whether or not virtualization is on.
2551
+ const visibleRowRange = $derived.by(function visibleRowRange_d() {
2552
+ if (!rowVirtualizationEnabled) {
2553
+ return { startIndex: 0, endIndex: Math.max(0, allRows.length - 1) };
2554
+ }
2555
+ const first = virtualRows[0];
2556
+ const last = virtualRows[virtualRows.length - 1];
2557
+ if (!first || !last)
2558
+ return { startIndex: 0, endIndex: 0 };
2559
+ return { startIndex: first.index, endIndex: last.index };
2560
+ });
2561
+ // Emitted from an effect rather than the derived so a consumer that
2562
+ // fetches (and therefore changes `data`) cannot re-enter the derivation.
2563
+ let lastReportedRange = "";
2564
+ $effect(() => {
2565
+ const report = props.onVisibleRangeChange;
2566
+ if (!report)
2567
+ return;
2568
+ const range = visibleRowRange;
2569
+ const key = range.startIndex + ":" + range.endIndex;
2570
+ if (key === lastReportedRange)
2571
+ return;
2572
+ lastReportedRange = key;
2573
+ report(range);
2574
+ });
2575
+ // A paging row model that asks for `autoPageSize` gets as many rows as
2576
+ // the body shows without scrolling, re-measured as the viewport changes.
2577
+ $effect(() => {
2578
+ rowModelVersion;
2579
+ const paging = rawProps.rowModel?.pagination;
2580
+ if (!paging?.autoPageSize)
2581
+ return;
2582
+ const height = viewportHeight;
2583
+ if (height <= 0)
2584
+ return;
2585
+ const rowPx = rowSizePxOf(0) || 30;
2586
+ const fit = Math.max(1, Math.floor(height / rowPx));
2587
+ if (fit !== paging.pageSize)
2588
+ paging.setPageSize(fit);
2589
+ });
2422
2590
  // --- Huge-list scroll scaling -----------------------------------------
2423
2591
  // Browsers cap how tall a single element may be, and mobile caps sit well
2424
2592
  // below desktop. Past a few hundred thousand rows the true content height
@@ -3147,7 +3315,12 @@ export function createSvGridController(rawProps, domIdBase) {
3147
3315
  // rows (10k iterations on the large-dataset demo, 100k on its top tier) to
3148
3316
  // recount a selection that had not changed.
3149
3317
  const headerSelectionState = $derived.by(function headerSelectionState_d() {
3150
- const selectable = allRows.filter((row) => !isGroupRow(row));
3318
+ // An external model knows about rows the grid has never seen, so it is
3319
+ // the only thing that can answer honestly once the data is a window
3320
+ // onto a server.
3321
+ if (props.rowSelectionModel)
3322
+ return props.rowSelectionModel.headerState();
3323
+ const selectable = allRows.filter((row) => !isGroupRow(row) && !placeholderStateOf(row));
3151
3324
  if (!selectable.length)
3152
3325
  return "none";
3153
3326
  let selected = 0;
@@ -3300,7 +3473,7 @@ export function createSvGridController(rawProps, domIdBase) {
3300
3473
  const meta = isBucketableColumn(column);
3301
3474
  if (!meta)
3302
3475
  continue;
3303
- const buckets = buildBuckets(column, meta.isDate, props.data, getColumnAccessorValue);
3476
+ const buckets = buildBuckets(column, meta.isDate, props.data ?? EMPTY_DATA, getColumnAccessorValue);
3304
3477
  if (buckets)
3305
3478
  map.set(column.id, buckets);
3306
3479
  }
@@ -3788,6 +3961,8 @@ export function createSvGridController(rawProps, domIdBase) {
3788
3961
  get selectionBarMaxVisible() { return selectionBarMaxVisible; },
3789
3962
  get selectionBarHideClear() { return selectionBarHideClear; },
3790
3963
  get selectionBarTarget() { return selectionBarTarget; },
3964
+ get selectionBarCount() { return selectionBarCount; },
3965
+ get selectionBarVisible() { return selectionBarVisible; },
3791
3966
  get lastSelectionSerialized() { return lastSelectionSerialized; },
3792
3967
  set lastSelectionSerialized(v) { lastSelectionSerialized = v; },
3793
3968
  get lastCellRangeSerialized() { return lastCellRangeSerialized; },
@@ -4125,7 +4300,14 @@ export function createSvGridController(rawProps, domIdBase) {
4125
4300
  get isRowSelected() { return isRowSelected; },
4126
4301
  get toggleRowSelectionById() { return toggleRowSelectionById; },
4127
4302
  get headerSelectionState() { return headerSelectionState; },
4303
+ get visibleRowRange() { return visibleRowRange; },
4304
+ placeholderStateOf,
4305
+ /** Retry a failed placeholder row. No-op without `onRetryRow`. */
4306
+ retryRow(row) {
4307
+ props.onRetryRow?.(row.original, row.index);
4308
+ },
4128
4309
  get toggleSelectAllRows() { return toggleSelectAllRows; },
4310
+ get setSelectAllRows() { return setSelectAllRows; },
4129
4311
  get userHasActivatedCell() { return userHasActivatedCell; },
4130
4312
  set userHasActivatedCell(v) { userHasActivatedCell = v; },
4131
4313
  get tabRunOrigin() { return tabRunOrigin; },
@@ -4274,7 +4456,7 @@ export function createSvGridController(rawProps, domIdBase) {
4274
4456
  const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, setFacetSelection, setFilterTokens, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus(ctx);
4275
4457
  const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, areEditorOptionsLoading, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender(ctx);
4276
4458
  const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste, armPasteFallback } = createEditing(ctx);
4277
- const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, activeRangeRect, cellSelectionState, getSelectionRects, isInFillPreview, dragPreviewRect, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection(ctx);
4459
+ 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(ctx);
4278
4460
  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(ctx);
4279
4461
  const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd, onRowPointerDown, destroyRowDrag } = createRowDrag(ctx);
4280
4462
  const { register: registerAlignedGrid, broadcastScroll: broadcastAlignedScroll, broadcastWidths: broadcastAlignedWidths } = createAlignedGrids(ctx);
@@ -4313,6 +4495,8 @@ export function createSvGridController(rawProps, domIdBase) {
4313
4495
  pageSize: props.pageSize,
4314
4496
  groupBy: props.groupBy,
4315
4497
  treeData: props.treeData,
4498
+ gantt: props.gantt,
4499
+ serverGroup: props.serverGroup,
4316
4500
  initialColumnPinning: props.initialColumnPinning,
4317
4501
  columnVirtualization: props.columnVirtualization,
4318
4502
  externalPagination: props.externalPagination,
package/dist/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
@@ -54,6 +54,7 @@
54
54
  import { getSchedulerView } from "./scheduler-view.svelte";
55
55
  import { getSelectionBarView } from "./selection-bar-view.svelte";
56
56
  import { getBoardView } from "./board-view.svelte";
57
+ import { getGanttView } from "./gantt-view.svelte";
57
58
  import { getChartView } from "./chart-view.svelte";
58
59
  let props: Props<TFeatures, TData> = $props();
59
60
  // Per-instance base for every DOM id the grid mints. `$props.id()` is stable
@@ -78,6 +79,9 @@
78
79
  const boardConfig = $derived(opt.board);
79
80
  // Scheduler / calendar mode: same "view of the grid" seam as the board.
80
81
  const schedulerConfig = $derived(opt.scheduler);
82
+ // Gantt mode: a task table beside a time chart. Same seam again; the renderer
83
+ // ships in @svgrid/enterprise (enableGanttView).
84
+ const ganttConfig = $derived(opt.gantt);
81
85
  // Chart mode: another "view of the grid". Unlike board/scheduler, the renderer
82
86
  // is free - a built-in default (SvGridChartView, lazy-loaded below) wraps the
83
87
  // standalone SvChart, overridable via the `chart-view` seam (getChartView).
@@ -408,6 +412,11 @@
408
412
  ctrl.columnVirtualizationEnabled,
409
413
  );
410
414
  const virtualRows = $derived(ctrl.virtualRows);
415
+ // The first body row on screen: where a failed run that started above
416
+ // the window repeats its message.
417
+ const firstRenderedRowIndex = $derived(
418
+ rowVirtualizationEnabled ? (virtualRows[0]?.index ?? 0) : 0,
419
+ );
411
420
  const frozenRowCount = $derived(ctrl.frozenRowCount);
412
421
  const frozenRowList = $derived(ctrl.frozenRowList);
413
422
  // Merged cells. A merge is drawn once per band (the frozen rows, the
@@ -1050,6 +1059,48 @@
1050
1059
  {/if}
1051
1060
  </div>
1052
1061
  </div>
1062
+ {:else if ganttConfig}
1063
+ <div
1064
+ class="sv-grid-root sv-grid-gantt-root"
1065
+ class:sv-grid-root-fill={opt.containerHeight === "100%"}
1066
+ style={`height: ${
1067
+ typeof opt.containerHeight === "string"
1068
+ ? opt.containerHeight
1069
+ : `${opt.containerHeight ?? 520}px`
1070
+ }; display: flex; flex-direction: column;`}
1071
+ >
1072
+ {#if ganttConfig.searchable !== false}
1073
+ <label class="sv-grid-board-search">
1074
+ {@render icon("search")}
1075
+ <input
1076
+ type="search"
1077
+ placeholder={ganttConfig.searchPlaceholder ??
1078
+ messages.ganttSearchPlaceholder}
1079
+ bind:value={ctrl.globalFilter}
1080
+ aria-label="Search tasks"
1081
+ />
1082
+ </label>
1083
+ {/if}
1084
+ <div style="flex: 1 1 auto; min-height: 0;">
1085
+ {#if getGanttView()}
1086
+ {@const GanttView = getGanttView()}
1087
+ <GanttView
1088
+ data={boardData}
1089
+ columns={opt.columns}
1090
+ gantt={ganttConfig}
1091
+ getRowId={opt.getRowId}
1092
+ />
1093
+ {:else}
1094
+ <!-- The Gantt renderer ships in @svgrid/enterprise. Call
1095
+ `enableGanttView()` (or `installEnterprise(api)`) to register it. -->
1096
+ <div class="sv-grid-scheduler-upsell sv-grid-gantt-upsell" role="note">
1097
+ <strong>{messages.ganttUpsellTitle}</strong>
1098
+ <p>{messages.ganttUpsellBody}</p>
1099
+ {@render enterpriseLicenseNote()}
1100
+ </div>
1101
+ {/if}
1102
+ </div>
1103
+ </div>
1053
1104
  {:else if chartViewConfig}
1054
1105
  <div
1055
1106
  class="sv-grid-root sv-grid-chart-root"
@@ -1461,6 +1512,73 @@
1461
1512
  </tr>
1462
1513
  {/snippet}
1463
1514
 
1515
+ <!-- A row whose data has not arrived.
1516
+
1517
+ "loading" keeps the real columns and draws a shimmer bar in each,
1518
+ so the table keeps its shape while blocks stream in - a full-width
1519
+ bar would make the whole grid jump every time one landed. "failed"
1520
+ is the opposite case: there is nothing per-column to say, just one
1521
+ message and a way to try again, so it spans the row like a detail
1522
+ cell does. Neither is selectable or editable; there is nothing
1523
+ there to act on.
1524
+
1525
+ A failed block is a run of failed rows, one per row it claimed,
1526
+ and one message serves the run: the first failed row on screen
1527
+ carries it (the run's first row, or the top of the window when
1528
+ the run started above it) and the rows under it are a plain
1529
+ tinted band. A hundred "Could not load" lines for one block
1530
+ was a wall of red. -->
1531
+ {#snippet placeholderRowMarkup(
1532
+ phRow: Row<TData>,
1533
+ phRowIndex: number,
1534
+ phState: "loading" | "failed",
1535
+ phStyle: string,
1536
+ phContinues: boolean,
1537
+ )}
1538
+ <tr
1539
+ class="sv-grid-row sv-grid-placeholder-row"
1540
+ class:sv-grid-placeholder-failed={phState === "failed"}
1541
+ class:sv-grid-placeholder-failed-cont={phContinues}
1542
+ aria-busy={phState === "loading" ? "true" : undefined}
1543
+ {...getGridRowA11yProps(phRowIndex + 1)}
1544
+ style={phStyle}
1545
+ >
1546
+ {#if phState === "failed"}
1547
+ <td
1548
+ class="sv-grid-cell sv-grid-placeholder-cell"
1549
+ colspan={allColumns.length +
1550
+ (showRowNumbersEffective ? 1 : 0) +
1551
+ (showRowSelectionEffective ? 1 : 0)}
1552
+ >
1553
+ {#if !phContinues}
1554
+ <div class="sv-grid-placeholder-failed-body">
1555
+ <span class="sv-grid-placeholder-error">{messages.rowLoadFailed}</span>
1556
+ {#if opt.onRetryRow}
1557
+ <button
1558
+ type="button"
1559
+ class="sv-grid-placeholder-retry"
1560
+ onclick={() => ctrl.retryRow(phRow)}>{messages.rowLoadRetry}</button
1561
+ >
1562
+ {/if}
1563
+ </div>
1564
+ {/if}
1565
+ </td>
1566
+ {:else}
1567
+ {#if showRowNumbersEffective}
1568
+ <td class="sv-grid-cell sv-grid-rownum-cell"></td>
1569
+ {/if}
1570
+ {#if showRowSelectionEffective}
1571
+ <td class="sv-grid-cell sv-grid-select-cell"></td>
1572
+ {/if}
1573
+ {#each allColumns as col (col.id)}
1574
+ <td class="sv-grid-cell sv-grid-placeholder-cell">
1575
+ <span class="sv-grid-placeholder-skeleton" aria-hidden="true"></span>
1576
+ </td>
1577
+ {/each}
1578
+ {/if}
1579
+ </tr>
1580
+ {/snippet}
1581
+
1464
1582
  <!-- A single pinned row (top or bottom). Read-only by design: no
1465
1583
  inline editing, no row-selection checkbox, no fill handle.
1466
1584
  Position-sticky CSS keeps it anchored to the top of the body or
@@ -1474,7 +1592,14 @@
1474
1592
  the virtualizer's measured size, or the declared one.
1475
1593
  -->
1476
1594
  {#snippet bodyRow(row: Row<TData>, rowIndex: number, rowStyle: string)}
1477
- {#if opt.isDetailRow?.(row.original as TData, rowIndex)}
1595
+ {@const phState = ctrl.placeholderStateOf(row)}
1596
+ {#if phState}
1597
+ {@const phContinues =
1598
+ phState === "failed" &&
1599
+ rowIndex > firstRenderedRowIndex &&
1600
+ ctrl.placeholderStateOf(allRows[rowIndex - 1]!) === "failed"}
1601
+ {@render placeholderRowMarkup(row, rowIndex, phState, rowStyle, phContinues)}
1602
+ {:else if opt.isDetailRow?.(row.original as TData, rowIndex)}
1478
1603
  {@render detailRowMarkup(row, rowIndex)}
1479
1604
  {:else if isGroupRow(row) && !groupColumnMode}
1480
1605
  <tr
@@ -1827,7 +1952,7 @@
1827
1952
  style={chartDockReserveStyle}
1828
1953
  data-move-grab={ctrl.moveGrabHover || ctrl.moveDrag ? "true" : undefined}
1829
1954
  data-selbar={
1830
- ctrl.selectionBarOn && ctrl.selectionBarTarget.ids.length > 0
1955
+ ctrl.selectionBarVisible
1831
1956
  ? (getSelectionBarView() ? ctrl.selectionBarPosition : `upsell-${ctrl.selectionBarPosition}`)
1832
1957
  : undefined
1833
1958
  }
@@ -3004,7 +3129,7 @@
3004
3129
  `absolute` against it, so moving it outside would anchor it to the
3005
3130
  page. The renderer is Enterprise and arrives through the
3006
3131
  selection-bar-view seam; without it, a short note in its place. -->
3007
- {#if ctrl.selectionBarOn && ctrl.selectionBarTarget.ids.length > 0}
3132
+ {#if ctrl.selectionBarVisible}
3008
3133
  {#if getSelectionBarView()}
3009
3134
  {@const SelectionBarView = getSelectionBarView()}
3010
3135
  <SelectionBarView {ctrl} />