@ornery/ui-grid-vanilla 0.1.8-hotfix-1 → 0.1.9

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.
package/dist/index.d.mts CHANGED
@@ -169,6 +169,7 @@ declare class UiGridStandaloneElement extends HTMLElement {
169
169
  private draggedColumnName;
170
170
  private dropTargetColumnName;
171
171
  private scrollPosition;
172
+ private horizontalScrollPosition;
172
173
  private scrollFrame;
173
174
  private suppressScrollEvent;
174
175
  private lastVirtualStartIndex;
@@ -178,6 +179,9 @@ declare class UiGridStandaloneElement extends HTMLElement {
178
179
  private benchmarkAverage;
179
180
  private skipNextRender;
180
181
  private dataFrame;
182
+ private pendingPatchedRowIds;
183
+ private pendingDataRefreshMode;
184
+ private lastScrollActivityAt;
181
185
  static get observedAttributes(): string[];
182
186
  get options(): GridOptions;
183
187
  set options(value: GridOptions);
@@ -273,6 +277,7 @@ declare class UiGridStandaloneElement extends HTMLElement {
273
277
  * (the fresh innerHTML already reflects the latest data).
274
278
  */
275
279
  setData(rows: GridRecord[]): void;
280
+ private flushPendingDataRefresh;
276
281
  private patchCells;
277
282
  connectedCallback(): void;
278
283
  disconnectedCallback(): void;
@@ -296,11 +301,14 @@ declare class UiGridStandaloneElement extends HTMLElement {
296
301
  private renderPagination;
297
302
  private renderSlotRegistry;
298
303
  private renderCellTemplate;
304
+ private renderCellTemplateFromMarkup;
299
305
  private renderExpandableTemplate;
300
306
  private getTemplateMarkup;
301
307
  private interpolateTemplate;
302
308
  private resolveTemplateValue;
303
309
  private rowIndexFor;
310
+ private classifyDataRefresh;
311
+ private getRenderedDisplayWindow;
304
312
  private cellSlotName;
305
313
  private downloadCsv;
306
314
  private renderControlIcon;
package/dist/index.d.ts CHANGED
@@ -169,6 +169,7 @@ declare class UiGridStandaloneElement extends HTMLElement {
169
169
  private draggedColumnName;
170
170
  private dropTargetColumnName;
171
171
  private scrollPosition;
172
+ private horizontalScrollPosition;
172
173
  private scrollFrame;
173
174
  private suppressScrollEvent;
174
175
  private lastVirtualStartIndex;
@@ -178,6 +179,9 @@ declare class UiGridStandaloneElement extends HTMLElement {
178
179
  private benchmarkAverage;
179
180
  private skipNextRender;
180
181
  private dataFrame;
182
+ private pendingPatchedRowIds;
183
+ private pendingDataRefreshMode;
184
+ private lastScrollActivityAt;
181
185
  static get observedAttributes(): string[];
182
186
  get options(): GridOptions;
183
187
  set options(value: GridOptions);
@@ -273,6 +277,7 @@ declare class UiGridStandaloneElement extends HTMLElement {
273
277
  * (the fresh innerHTML already reflects the latest data).
274
278
  */
275
279
  setData(rows: GridRecord[]): void;
280
+ private flushPendingDataRefresh;
276
281
  private patchCells;
277
282
  connectedCallback(): void;
278
283
  disconnectedCallback(): void;
@@ -296,11 +301,14 @@ declare class UiGridStandaloneElement extends HTMLElement {
296
301
  private renderPagination;
297
302
  private renderSlotRegistry;
298
303
  private renderCellTemplate;
304
+ private renderCellTemplateFromMarkup;
299
305
  private renderExpandableTemplate;
300
306
  private getTemplateMarkup;
301
307
  private interpolateTemplate;
302
308
  private resolveTemplateValue;
303
309
  private rowIndexFor;
310
+ private classifyDataRefresh;
311
+ private getRenderedDisplayWindow;
304
312
  private cellSlotName;
305
313
  private downloadCsv;
306
314
  private renderControlIcon;
package/dist/index.js CHANGED
@@ -779,6 +779,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
779
779
  draggedColumnName = null;
780
780
  dropTargetColumnName = null;
781
781
  scrollPosition = 0;
782
+ horizontalScrollPosition = 0;
782
783
  scrollFrame = null;
783
784
  suppressScrollEvent = false;
784
785
  lastVirtualStartIndex = -1;
@@ -788,6 +789,9 @@ var UiGridStandaloneElement = class extends HTMLElement {
788
789
  benchmarkAverage = "\u2014";
789
790
  skipNextRender = false;
790
791
  dataFrame = null;
792
+ pendingPatchedRowIds = null;
793
+ pendingDataRefreshMode = null;
794
+ lastScrollActivityAt = 0;
791
795
  static get observedAttributes() {
792
796
  return [
793
797
  // Scalar attributes
@@ -833,10 +837,14 @@ var UiGridStandaloneElement = class extends HTMLElement {
833
837
  ];
834
838
  }
835
839
  get options() {
836
- return this.activeOptions ?? {
840
+ if (this.activeOptions !== null) {
841
+ return this.buildEffectiveOptions(this.activeOptions);
842
+ }
843
+ return {
837
844
  id: "__ui-grid-pending__",
838
845
  data: [],
839
- columnDefs: []
846
+ columnDefs: [],
847
+ ...this.attributeOptions
840
848
  };
841
849
  }
842
850
  set options(value) {
@@ -959,8 +967,15 @@ var UiGridStandaloneElement = class extends HTMLElement {
959
967
  );
960
968
  if (paginationPageSizes !== void 0)
961
969
  this.attributeOptions.paginationPageSizes = paginationPageSizes;
962
- if (this.activeOptions) {
970
+ if (this.activeOptions !== null) {
963
971
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
972
+ } else {
973
+ this.ensureController({
974
+ id: "__ui-grid-pending__",
975
+ data: [],
976
+ columnDefs: [],
977
+ ...this.attributeOptions
978
+ });
964
979
  }
965
980
  }
966
981
  parseBooleanAttribute(name) {
@@ -1272,18 +1287,49 @@ var UiGridStandaloneElement = class extends HTMLElement {
1272
1287
  if (!controller) {
1273
1288
  return;
1274
1289
  }
1290
+ const previousSnapshot = this.snapshot;
1275
1291
  this.skipNextRender = true;
1276
1292
  controller.refreshData(rows);
1277
1293
  this.skipNextRender = false;
1294
+ const { mode, changedRowIds } = this.classifyDataRefresh(previousSnapshot, this.snapshot);
1295
+ if (mode === null) {
1296
+ return;
1297
+ }
1298
+ this.pendingDataRefreshMode = mode;
1299
+ this.pendingPatchedRowIds = changedRowIds;
1278
1300
  if (this.dataFrame !== null) {
1279
1301
  cancelAnimationFrame(this.dataFrame);
1280
1302
  }
1281
1303
  this.dataFrame = requestAnimationFrame(() => {
1282
- this.dataFrame = null;
1283
- this.patchCells();
1304
+ this.flushPendingDataRefresh();
1284
1305
  });
1285
1306
  }
1286
- patchCells() {
1307
+ flushPendingDataRefresh() {
1308
+ this.dataFrame = null;
1309
+ const mode = this.pendingDataRefreshMode;
1310
+ if (mode === null) {
1311
+ return;
1312
+ }
1313
+ if (Date.now() - this.lastScrollActivityAt < 80) {
1314
+ this.dataFrame = requestAnimationFrame(() => {
1315
+ this.flushPendingDataRefresh();
1316
+ });
1317
+ return;
1318
+ }
1319
+ const changedRowIds = this.pendingPatchedRowIds;
1320
+ this.pendingDataRefreshMode = null;
1321
+ this.pendingPatchedRowIds = null;
1322
+ if (mode === "patch") {
1323
+ this.patchCells(changedRowIds ?? void 0);
1324
+ return;
1325
+ }
1326
+ if (mode === "virtual") {
1327
+ this.renderVirtualBody();
1328
+ return;
1329
+ }
1330
+ this.render();
1331
+ }
1332
+ patchCells(changedRowIds) {
1287
1333
  const snapshot = this.snapshot;
1288
1334
  if (!snapshot) {
1289
1335
  return;
@@ -1293,30 +1339,37 @@ var UiGridStandaloneElement = class extends HTMLElement {
1293
1339
  return;
1294
1340
  }
1295
1341
  const rowMap = /* @__PURE__ */ new Map();
1342
+ const rowIndexMap = /* @__PURE__ */ new Map();
1296
1343
  for (const row of snapshot.pipeline.visibleRows) {
1297
1344
  rowMap.set(row.id, row);
1345
+ rowIndexMap.set(row.id, rowIndexMap.size);
1298
1346
  }
1299
1347
  const colMap = /* @__PURE__ */ new Map();
1348
+ const templateMarkupMap = /* @__PURE__ */ new Map();
1300
1349
  for (const col of snapshot.visibleColumns) {
1301
1350
  colMap.set(col.name, col);
1351
+ templateMarkupMap.set(col.name, this.getTemplateMarkup(this.cellSlotName(col)));
1302
1352
  }
1303
1353
  const cells = root.querySelectorAll("[data-row][data-column]");
1304
1354
  for (const cell of cells) {
1305
1355
  const rowId = cell.dataset["row"];
1306
1356
  const colName = cell.dataset["column"];
1307
1357
  if (!rowId || !colName) continue;
1358
+ if (changedRowIds && !changedRowIds.has(rowId)) continue;
1308
1359
  const row = rowMap.get(rowId);
1309
1360
  const column = colMap.get(colName);
1310
1361
  if (!row || !column) continue;
1311
- const cellShell = cell.querySelector(".cell-shell");
1312
- if (!cellShell) continue;
1313
- const newContent = this.renderCellTemplate(row, column, 0);
1314
- const toggles = cellShell.querySelectorAll(".row-toggle");
1315
- if (toggles.length === 0) {
1316
- cellShell.innerHTML = newContent;
1317
- } else {
1318
- const toggleHtml = Array.from(toggles).map((t) => t.outerHTML).join("");
1319
- cellShell.innerHTML = toggleHtml + newContent;
1362
+ if (cell.classList.contains("cell-editing")) continue;
1363
+ const cellContent = cell.querySelector(".cell-content");
1364
+ if (!cellContent) continue;
1365
+ const newContent = this.renderCellTemplateFromMarkup(
1366
+ row,
1367
+ column,
1368
+ rowIndexMap.get(rowId) ?? 0,
1369
+ templateMarkupMap.get(colName) ?? null
1370
+ );
1371
+ if (cellContent.innerHTML !== newContent) {
1372
+ cellContent.innerHTML = newContent;
1320
1373
  }
1321
1374
  }
1322
1375
  }
@@ -1668,7 +1721,9 @@ var UiGridStandaloneElement = class extends HTMLElement {
1668
1721
  if (!target?.classList.contains("grid-table") || this.suppressScrollEvent) {
1669
1722
  return;
1670
1723
  }
1724
+ this.lastScrollActivityAt = Date.now();
1671
1725
  this.scrollPosition = target.scrollTop;
1726
+ this.horizontalScrollPosition = target.scrollLeft;
1672
1727
  if (!this.snapshot?.pipeline.virtualizationEnabled) {
1673
1728
  return;
1674
1729
  }
@@ -1749,6 +1804,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
1749
1804
  const previousGridTable = root.querySelector(".grid-table");
1750
1805
  if (previousGridTable && !this.suppressScrollEvent) {
1751
1806
  this.scrollPosition = previousGridTable.scrollTop;
1807
+ this.horizontalScrollPosition = previousGridTable.scrollLeft;
1752
1808
  }
1753
1809
  if (!snapshot) {
1754
1810
  root.innerHTML = `<style>${import_ui_grid_core2.GRID_CORE_CSS}</style><section class="grid-shell ui-grid-shell"><div class="empty-state ui-grid-no-row-overlay"><strong>No grid options provided.</strong></div></section>`;
@@ -1795,12 +1851,18 @@ var UiGridStandaloneElement = class extends HTMLElement {
1795
1851
  const bodyContent = itemsToRender.map((item, index) => this.renderDisplayItem(item, startIndex + index)).join("");
1796
1852
  const body = snapshot.pipeline.displayItems.length > 0 ? virtualizationEnabled ? `<div class="grid-virtual-spacer" style="height:${totalVirtualHeight}px"><div class="body-grid ui-grid-canvas grid-virtual-body" style="grid-template-columns:${templateColumns};top:${virtualOffset}px">${bodyContent}</div></div>` : `<div class="body-grid ui-grid-canvas" style="grid-template-columns:${templateColumns}">${bodyContent}</div>` : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
1797
1853
  const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : "";
1798
- const gridTableStyle = `${virtualizationEnabled ? `height:${viewportHeight}px;overflow-y:auto;` : ""}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1854
+ const hasViewportScroll = virtualizationEnabled || options.viewportHeight !== void 0;
1855
+ const gridTableStyle = `${hasViewportScroll ? `height:${viewportHeight}px;overflow-y:auto;` : ""}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1799
1856
  root.innerHTML = `<style>${import_ui_grid_core2.GRID_CORE_CSS}</style>${slotRegistry}<section class="grid-frame ui-grid" role="grid" aria-label="${escapeHtml(options.title ?? "Data grid")}"><div class="grid-table ui-grid-contents-wrapper" style="${gridTableStyle}"><div class="header-grid ui-grid-header ui-grid-header-canvas" style="grid-template-columns:${templateColumns}">${header}</div>${filterRow}${body}</div>${pagination}</section>`;
1800
1857
  const gridTable = root.querySelector(".grid-table");
1801
- if (gridTable && this.scrollPosition > 0 && virtualizationEnabled) {
1858
+ if (gridTable && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
1802
1859
  this.suppressScrollEvent = true;
1803
- gridTable.scrollTop = this.scrollPosition;
1860
+ if (this.scrollPosition > 0) {
1861
+ gridTable.scrollTop = this.scrollPosition;
1862
+ }
1863
+ if (this.horizontalScrollPosition > 0) {
1864
+ gridTable.scrollLeft = this.horizontalScrollPosition;
1865
+ }
1804
1866
  requestAnimationFrame(() => {
1805
1867
  this.suppressScrollEvent = false;
1806
1868
  });
@@ -1912,7 +1974,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
1912
1974
  editing ? "cell-editing" : ""
1913
1975
  ].filter(Boolean).join(" ");
1914
1976
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : "";
1915
- return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}${content}</div></div>`;
1977
+ return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></div>`;
1916
1978
  }
1917
1979
  renderPagination(snapshot) {
1918
1980
  const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
@@ -1926,6 +1988,9 @@ var UiGridStandaloneElement = class extends HTMLElement {
1926
1988
  }
1927
1989
  renderCellTemplate(row, column, displayIndex) {
1928
1990
  const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
1991
+ return this.renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup);
1992
+ }
1993
+ renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup) {
1929
1994
  if (!templateMarkup) {
1930
1995
  return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? "")}</span>`;
1931
1996
  }
@@ -1978,6 +2043,79 @@ var UiGridStandaloneElement = class extends HTMLElement {
1978
2043
  rowIndexFor(row, fallback = 0) {
1979
2044
  return this.snapshot?.pipeline.visibleRows.findIndex((candidate) => candidate.id === row.id) ?? fallback;
1980
2045
  }
2046
+ classifyDataRefresh(previousSnapshot, nextSnapshot) {
2047
+ if (!nextSnapshot) {
2048
+ return { mode: "full", changedRowIds: null };
2049
+ }
2050
+ if (!previousSnapshot) {
2051
+ return {
2052
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2053
+ changedRowIds: null
2054
+ };
2055
+ }
2056
+ if (previousSnapshot.visibleColumns.length !== nextSnapshot.visibleColumns.length) {
2057
+ return { mode: "full", changedRowIds: null };
2058
+ }
2059
+ for (let index = 0; index < previousSnapshot.visibleColumns.length; index += 1) {
2060
+ if (previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name) {
2061
+ return { mode: "full", changedRowIds: null };
2062
+ }
2063
+ }
2064
+ const previousWindow = this.getRenderedDisplayWindow(previousSnapshot);
2065
+ const nextWindow = this.getRenderedDisplayWindow(nextSnapshot);
2066
+ if (previousWindow.startIndex !== nextWindow.startIndex || previousWindow.items.length !== nextWindow.items.length) {
2067
+ return {
2068
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2069
+ changedRowIds: null
2070
+ };
2071
+ }
2072
+ const changedRowIds = /* @__PURE__ */ new Set();
2073
+ for (let index = 0; index < previousWindow.items.length; index += 1) {
2074
+ const previousItem = previousWindow.items[index];
2075
+ const nextItem = nextWindow.items[index];
2076
+ if (!previousItem || !nextItem || previousItem.kind !== "row" || nextItem.kind !== "row") {
2077
+ return {
2078
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2079
+ changedRowIds: null
2080
+ };
2081
+ }
2082
+ if (previousItem.row.id !== nextItem.row.id) {
2083
+ return {
2084
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2085
+ changedRowIds: null
2086
+ };
2087
+ }
2088
+ if (previousItem.row.entity !== nextItem.row.entity) {
2089
+ changedRowIds.add(nextItem.row.id);
2090
+ }
2091
+ }
2092
+ return {
2093
+ mode: changedRowIds.size > 0 ? "patch" : null,
2094
+ changedRowIds: changedRowIds.size > 0 ? changedRowIds : null
2095
+ };
2096
+ }
2097
+ getRenderedDisplayWindow(snapshot) {
2098
+ if (!snapshot.pipeline.virtualizationEnabled) {
2099
+ return {
2100
+ startIndex: 0,
2101
+ items: snapshot.pipeline.displayItems
2102
+ };
2103
+ }
2104
+ const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
2105
+ const viewportHeight = snapshot.options.viewportHeight ?? 560;
2106
+ const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
2107
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
2108
+ const overscan = 4;
2109
+ const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
2110
+ const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
2111
+ return {
2112
+ startIndex,
2113
+ items: snapshot.pipeline.displayItems.slice(
2114
+ startIndex,
2115
+ Math.min(snapshot.pipeline.displayItems.length, startIndex + visibleCount)
2116
+ )
2117
+ };
2118
+ }
1981
2119
  cellSlotName(column) {
1982
2120
  return `cell-${column.name}`;
1983
2121
  }
package/dist/index.mjs CHANGED
@@ -821,6 +821,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
821
821
  draggedColumnName = null;
822
822
  dropTargetColumnName = null;
823
823
  scrollPosition = 0;
824
+ horizontalScrollPosition = 0;
824
825
  scrollFrame = null;
825
826
  suppressScrollEvent = false;
826
827
  lastVirtualStartIndex = -1;
@@ -830,6 +831,9 @@ var UiGridStandaloneElement = class extends HTMLElement {
830
831
  benchmarkAverage = "\u2014";
831
832
  skipNextRender = false;
832
833
  dataFrame = null;
834
+ pendingPatchedRowIds = null;
835
+ pendingDataRefreshMode = null;
836
+ lastScrollActivityAt = 0;
833
837
  static get observedAttributes() {
834
838
  return [
835
839
  // Scalar attributes
@@ -875,10 +879,14 @@ var UiGridStandaloneElement = class extends HTMLElement {
875
879
  ];
876
880
  }
877
881
  get options() {
878
- return this.activeOptions ?? {
882
+ if (this.activeOptions !== null) {
883
+ return this.buildEffectiveOptions(this.activeOptions);
884
+ }
885
+ return {
879
886
  id: "__ui-grid-pending__",
880
887
  data: [],
881
- columnDefs: []
888
+ columnDefs: [],
889
+ ...this.attributeOptions
882
890
  };
883
891
  }
884
892
  set options(value) {
@@ -1001,8 +1009,15 @@ var UiGridStandaloneElement = class extends HTMLElement {
1001
1009
  );
1002
1010
  if (paginationPageSizes !== void 0)
1003
1011
  this.attributeOptions.paginationPageSizes = paginationPageSizes;
1004
- if (this.activeOptions) {
1012
+ if (this.activeOptions !== null) {
1005
1013
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
1014
+ } else {
1015
+ this.ensureController({
1016
+ id: "__ui-grid-pending__",
1017
+ data: [],
1018
+ columnDefs: [],
1019
+ ...this.attributeOptions
1020
+ });
1006
1021
  }
1007
1022
  }
1008
1023
  parseBooleanAttribute(name) {
@@ -1314,18 +1329,49 @@ var UiGridStandaloneElement = class extends HTMLElement {
1314
1329
  if (!controller) {
1315
1330
  return;
1316
1331
  }
1332
+ const previousSnapshot = this.snapshot;
1317
1333
  this.skipNextRender = true;
1318
1334
  controller.refreshData(rows);
1319
1335
  this.skipNextRender = false;
1336
+ const { mode, changedRowIds } = this.classifyDataRefresh(previousSnapshot, this.snapshot);
1337
+ if (mode === null) {
1338
+ return;
1339
+ }
1340
+ this.pendingDataRefreshMode = mode;
1341
+ this.pendingPatchedRowIds = changedRowIds;
1320
1342
  if (this.dataFrame !== null) {
1321
1343
  cancelAnimationFrame(this.dataFrame);
1322
1344
  }
1323
1345
  this.dataFrame = requestAnimationFrame(() => {
1324
- this.dataFrame = null;
1325
- this.patchCells();
1346
+ this.flushPendingDataRefresh();
1326
1347
  });
1327
1348
  }
1328
- patchCells() {
1349
+ flushPendingDataRefresh() {
1350
+ this.dataFrame = null;
1351
+ const mode = this.pendingDataRefreshMode;
1352
+ if (mode === null) {
1353
+ return;
1354
+ }
1355
+ if (Date.now() - this.lastScrollActivityAt < 80) {
1356
+ this.dataFrame = requestAnimationFrame(() => {
1357
+ this.flushPendingDataRefresh();
1358
+ });
1359
+ return;
1360
+ }
1361
+ const changedRowIds = this.pendingPatchedRowIds;
1362
+ this.pendingDataRefreshMode = null;
1363
+ this.pendingPatchedRowIds = null;
1364
+ if (mode === "patch") {
1365
+ this.patchCells(changedRowIds ?? void 0);
1366
+ return;
1367
+ }
1368
+ if (mode === "virtual") {
1369
+ this.renderVirtualBody();
1370
+ return;
1371
+ }
1372
+ this.render();
1373
+ }
1374
+ patchCells(changedRowIds) {
1329
1375
  const snapshot = this.snapshot;
1330
1376
  if (!snapshot) {
1331
1377
  return;
@@ -1335,30 +1381,37 @@ var UiGridStandaloneElement = class extends HTMLElement {
1335
1381
  return;
1336
1382
  }
1337
1383
  const rowMap = /* @__PURE__ */ new Map();
1384
+ const rowIndexMap = /* @__PURE__ */ new Map();
1338
1385
  for (const row of snapshot.pipeline.visibleRows) {
1339
1386
  rowMap.set(row.id, row);
1387
+ rowIndexMap.set(row.id, rowIndexMap.size);
1340
1388
  }
1341
1389
  const colMap = /* @__PURE__ */ new Map();
1390
+ const templateMarkupMap = /* @__PURE__ */ new Map();
1342
1391
  for (const col of snapshot.visibleColumns) {
1343
1392
  colMap.set(col.name, col);
1393
+ templateMarkupMap.set(col.name, this.getTemplateMarkup(this.cellSlotName(col)));
1344
1394
  }
1345
1395
  const cells = root.querySelectorAll("[data-row][data-column]");
1346
1396
  for (const cell of cells) {
1347
1397
  const rowId = cell.dataset["row"];
1348
1398
  const colName = cell.dataset["column"];
1349
1399
  if (!rowId || !colName) continue;
1400
+ if (changedRowIds && !changedRowIds.has(rowId)) continue;
1350
1401
  const row = rowMap.get(rowId);
1351
1402
  const column = colMap.get(colName);
1352
1403
  if (!row || !column) continue;
1353
- const cellShell = cell.querySelector(".cell-shell");
1354
- if (!cellShell) continue;
1355
- const newContent = this.renderCellTemplate(row, column, 0);
1356
- const toggles = cellShell.querySelectorAll(".row-toggle");
1357
- if (toggles.length === 0) {
1358
- cellShell.innerHTML = newContent;
1359
- } else {
1360
- const toggleHtml = Array.from(toggles).map((t) => t.outerHTML).join("");
1361
- cellShell.innerHTML = toggleHtml + newContent;
1404
+ if (cell.classList.contains("cell-editing")) continue;
1405
+ const cellContent = cell.querySelector(".cell-content");
1406
+ if (!cellContent) continue;
1407
+ const newContent = this.renderCellTemplateFromMarkup(
1408
+ row,
1409
+ column,
1410
+ rowIndexMap.get(rowId) ?? 0,
1411
+ templateMarkupMap.get(colName) ?? null
1412
+ );
1413
+ if (cellContent.innerHTML !== newContent) {
1414
+ cellContent.innerHTML = newContent;
1362
1415
  }
1363
1416
  }
1364
1417
  }
@@ -1710,7 +1763,9 @@ var UiGridStandaloneElement = class extends HTMLElement {
1710
1763
  if (!target?.classList.contains("grid-table") || this.suppressScrollEvent) {
1711
1764
  return;
1712
1765
  }
1766
+ this.lastScrollActivityAt = Date.now();
1713
1767
  this.scrollPosition = target.scrollTop;
1768
+ this.horizontalScrollPosition = target.scrollLeft;
1714
1769
  if (!this.snapshot?.pipeline.virtualizationEnabled) {
1715
1770
  return;
1716
1771
  }
@@ -1791,6 +1846,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
1791
1846
  const previousGridTable = root.querySelector(".grid-table");
1792
1847
  if (previousGridTable && !this.suppressScrollEvent) {
1793
1848
  this.scrollPosition = previousGridTable.scrollTop;
1849
+ this.horizontalScrollPosition = previousGridTable.scrollLeft;
1794
1850
  }
1795
1851
  if (!snapshot) {
1796
1852
  root.innerHTML = `<style>${GRID_CORE_CSS}</style><section class="grid-shell ui-grid-shell"><div class="empty-state ui-grid-no-row-overlay"><strong>No grid options provided.</strong></div></section>`;
@@ -1837,12 +1893,18 @@ var UiGridStandaloneElement = class extends HTMLElement {
1837
1893
  const bodyContent = itemsToRender.map((item, index) => this.renderDisplayItem(item, startIndex + index)).join("");
1838
1894
  const body = snapshot.pipeline.displayItems.length > 0 ? virtualizationEnabled ? `<div class="grid-virtual-spacer" style="height:${totalVirtualHeight}px"><div class="body-grid ui-grid-canvas grid-virtual-body" style="grid-template-columns:${templateColumns};top:${virtualOffset}px">${bodyContent}</div></div>` : `<div class="body-grid ui-grid-canvas" style="grid-template-columns:${templateColumns}">${bodyContent}</div>` : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
1839
1895
  const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : "";
1840
- const gridTableStyle = `${virtualizationEnabled ? `height:${viewportHeight}px;overflow-y:auto;` : ""}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1896
+ const hasViewportScroll = virtualizationEnabled || options.viewportHeight !== void 0;
1897
+ const gridTableStyle = `${hasViewportScroll ? `height:${viewportHeight}px;overflow-y:auto;` : ""}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1841
1898
  root.innerHTML = `<style>${GRID_CORE_CSS}</style>${slotRegistry}<section class="grid-frame ui-grid" role="grid" aria-label="${escapeHtml(options.title ?? "Data grid")}"><div class="grid-table ui-grid-contents-wrapper" style="${gridTableStyle}"><div class="header-grid ui-grid-header ui-grid-header-canvas" style="grid-template-columns:${templateColumns}">${header}</div>${filterRow}${body}</div>${pagination}</section>`;
1842
1899
  const gridTable = root.querySelector(".grid-table");
1843
- if (gridTable && this.scrollPosition > 0 && virtualizationEnabled) {
1900
+ if (gridTable && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
1844
1901
  this.suppressScrollEvent = true;
1845
- gridTable.scrollTop = this.scrollPosition;
1902
+ if (this.scrollPosition > 0) {
1903
+ gridTable.scrollTop = this.scrollPosition;
1904
+ }
1905
+ if (this.horizontalScrollPosition > 0) {
1906
+ gridTable.scrollLeft = this.horizontalScrollPosition;
1907
+ }
1846
1908
  requestAnimationFrame(() => {
1847
1909
  this.suppressScrollEvent = false;
1848
1910
  });
@@ -1954,7 +2016,7 @@ var UiGridStandaloneElement = class extends HTMLElement {
1954
2016
  editing ? "cell-editing" : ""
1955
2017
  ].filter(Boolean).join(" ");
1956
2018
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : "";
1957
- return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}${content}</div></div>`;
2019
+ return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></div>`;
1958
2020
  }
1959
2021
  renderPagination(snapshot) {
1960
2022
  const pageSizes = snapshot.options.paginationPageSizes ?? [10, 25, 50, 100];
@@ -1968,6 +2030,9 @@ var UiGridStandaloneElement = class extends HTMLElement {
1968
2030
  }
1969
2031
  renderCellTemplate(row, column, displayIndex) {
1970
2032
  const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
2033
+ return this.renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup);
2034
+ }
2035
+ renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup) {
1971
2036
  if (!templateMarkup) {
1972
2037
  return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? "")}</span>`;
1973
2038
  }
@@ -2020,6 +2085,79 @@ var UiGridStandaloneElement = class extends HTMLElement {
2020
2085
  rowIndexFor(row, fallback = 0) {
2021
2086
  return this.snapshot?.pipeline.visibleRows.findIndex((candidate) => candidate.id === row.id) ?? fallback;
2022
2087
  }
2088
+ classifyDataRefresh(previousSnapshot, nextSnapshot) {
2089
+ if (!nextSnapshot) {
2090
+ return { mode: "full", changedRowIds: null };
2091
+ }
2092
+ if (!previousSnapshot) {
2093
+ return {
2094
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2095
+ changedRowIds: null
2096
+ };
2097
+ }
2098
+ if (previousSnapshot.visibleColumns.length !== nextSnapshot.visibleColumns.length) {
2099
+ return { mode: "full", changedRowIds: null };
2100
+ }
2101
+ for (let index = 0; index < previousSnapshot.visibleColumns.length; index += 1) {
2102
+ if (previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name) {
2103
+ return { mode: "full", changedRowIds: null };
2104
+ }
2105
+ }
2106
+ const previousWindow = this.getRenderedDisplayWindow(previousSnapshot);
2107
+ const nextWindow = this.getRenderedDisplayWindow(nextSnapshot);
2108
+ if (previousWindow.startIndex !== nextWindow.startIndex || previousWindow.items.length !== nextWindow.items.length) {
2109
+ return {
2110
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2111
+ changedRowIds: null
2112
+ };
2113
+ }
2114
+ const changedRowIds = /* @__PURE__ */ new Set();
2115
+ for (let index = 0; index < previousWindow.items.length; index += 1) {
2116
+ const previousItem = previousWindow.items[index];
2117
+ const nextItem = nextWindow.items[index];
2118
+ if (!previousItem || !nextItem || previousItem.kind !== "row" || nextItem.kind !== "row") {
2119
+ return {
2120
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2121
+ changedRowIds: null
2122
+ };
2123
+ }
2124
+ if (previousItem.row.id !== nextItem.row.id) {
2125
+ return {
2126
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? "virtual" : "full",
2127
+ changedRowIds: null
2128
+ };
2129
+ }
2130
+ if (previousItem.row.entity !== nextItem.row.entity) {
2131
+ changedRowIds.add(nextItem.row.id);
2132
+ }
2133
+ }
2134
+ return {
2135
+ mode: changedRowIds.size > 0 ? "patch" : null,
2136
+ changedRowIds: changedRowIds.size > 0 ? changedRowIds : null
2137
+ };
2138
+ }
2139
+ getRenderedDisplayWindow(snapshot) {
2140
+ if (!snapshot.pipeline.virtualizationEnabled) {
2141
+ return {
2142
+ startIndex: 0,
2143
+ items: snapshot.pipeline.displayItems
2144
+ };
2145
+ }
2146
+ const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
2147
+ const viewportHeight = snapshot.options.viewportHeight ?? 560;
2148
+ const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
2149
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
2150
+ const overscan = 4;
2151
+ const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
2152
+ const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
2153
+ return {
2154
+ startIndex,
2155
+ items: snapshot.pipeline.displayItems.slice(
2156
+ startIndex,
2157
+ Math.min(snapshot.pipeline.displayItems.length, startIndex + visibleCount)
2158
+ )
2159
+ };
2160
+ }
2023
2161
  cellSlotName(column) {
2024
2162
  return `cell-${column.name}`;
2025
2163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornery/ui-grid-vanilla",
3
- "version": "0.1.8-hotfix-1",
3
+ "version": "0.1.9",
4
4
  "description": "Vanilla DOM wrapper for the Rust/WASM-backed @ornery/ui-grid-core custom element",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -16,7 +16,7 @@
16
16
  }
17
17
  },
18
18
  "dependencies": {
19
- "@ornery/ui-grid-core": "0.1.8-hotfix-1"
19
+ "@ornery/ui-grid-core": "0.1.9"
20
20
  },
21
21
  "devDependencies": {
22
22
  "typescript": "~5.8.0",
@@ -6,6 +6,7 @@ import {
6
6
  clearRustWasmGridEngine,
7
7
  } from '@ornery/ui-grid-core';
8
8
  import {
9
+ defineStandaloneUiGridElement,
9
10
  mountVanillaUiGrid,
10
11
  registerVanillaUiGridRustModule,
11
12
  type GridOptions,
@@ -274,4 +275,34 @@ describe('mountVanillaUiGrid integration', () => {
274
275
 
275
276
  expect(detailCard.textContent).toContain('Owner Mina Patel');
276
277
  });
278
+
279
+ it('preserves declarative attribute data when augmenting options imperatively', async () => {
280
+ // Use the already-registered 'ui-grid-element-vanilla-test' tag (from mountVanillaUiGrid
281
+ // in prior tests) to avoid the jsdom constraint of not registering the same class twice.
282
+ const grid = document.createElement('ui-grid-element-vanilla-test') as HTMLElement & {
283
+ options: GridOptions;
284
+ };
285
+ document.getElementById('app')!.appendChild(grid);
286
+
287
+ grid.setAttribute('grid-id', 'declarative-test');
288
+ grid.setAttribute('column-defs', JSON.stringify([{ name: 'name', displayName: 'Customer' }]));
289
+ grid.setAttribute('data', JSON.stringify([{ id: 'row-1', name: 'Gamma' }]));
290
+
291
+ // Flush the attributeChangedCallback microtask so attributeOptions are populated.
292
+ await new Promise<void>((r) => setTimeout(r, 0));
293
+
294
+ // Before any imperative assignment, the getter must surface attribute-derived data.
295
+ expect((grid.options.data as Array<{ id: string; name: string }>)[0].name).toBe('Gamma');
296
+ expect(grid.options.columnDefs[0].name).toBe('name');
297
+
298
+ // Simulate the bridge pattern: augment without losing attribute-derived data.
299
+ grid.options = {
300
+ ...grid.options,
301
+ viewportHeight: 999,
302
+ };
303
+
304
+ expect((grid.options.data as Array<{ id: string; name: string }>)[0].name).toBe('Gamma');
305
+ expect(grid.options.viewportHeight).toBe(999);
306
+ });
307
+
277
308
  });
package/src/index.test.ts CHANGED
@@ -40,7 +40,7 @@ describe('mountVanillaUiGrid', () => {
40
40
  expect(registerRustWasmGridEngine).toHaveBeenCalledTimes(1);
41
41
  expect(customElements.get('ui-grid-element')).toBeDefined();
42
42
  expect(grid.tagName.toLowerCase()).toBe('ui-grid-element');
43
- expect(grid.options).toBe(options);
43
+ expect(grid.options).toStrictEqual(options);
44
44
  expect(target.firstElementChild).toBe(grid);
45
45
  });
46
46
 
@@ -112,6 +112,7 @@ export class UiGridStandaloneElement extends HTMLElement {
112
112
  private draggedColumnName: string | null = null;
113
113
  private dropTargetColumnName: string | null = null;
114
114
  private scrollPosition = 0;
115
+ private horizontalScrollPosition = 0;
115
116
  private scrollFrame: number | null = null;
116
117
  private suppressScrollEvent = false;
117
118
  private lastVirtualStartIndex = -1;
@@ -121,6 +122,9 @@ export class UiGridStandaloneElement extends HTMLElement {
121
122
  private benchmarkAverage = '—';
122
123
  private skipNextRender = false;
123
124
  private dataFrame: number | null = null;
125
+ private pendingPatchedRowIds: Set<string> | null = null;
126
+ private pendingDataRefreshMode: 'patch' | 'virtual' | 'full' | null = null;
127
+ private lastScrollActivityAt = 0;
124
128
 
125
129
  static get observedAttributes(): string[] {
126
130
  return [
@@ -168,13 +172,16 @@ export class UiGridStandaloneElement extends HTMLElement {
168
172
  }
169
173
 
170
174
  get options(): GridOptions {
171
- return (
172
- this.activeOptions ?? {
173
- id: '__ui-grid-pending__',
174
- data: [],
175
- columnDefs: [],
176
- }
177
- );
175
+ if (this.activeOptions !== null) {
176
+ return this.buildEffectiveOptions(this.activeOptions);
177
+ }
178
+ // No imperative options set yet: safe defaults with attributes taking priority.
179
+ return {
180
+ id: '__ui-grid-pending__',
181
+ data: [],
182
+ columnDefs: [],
183
+ ...this.attributeOptions,
184
+ } as GridOptions;
178
185
  }
179
186
 
180
187
  set options(value: GridOptions) {
@@ -347,8 +354,16 @@ export class UiGridStandaloneElement extends HTMLElement {
347
354
  this.attributeOptions.paginationPageSizes = paginationPageSizes;
348
355
 
349
356
  // Re-render with the merged options.
350
- if (this.activeOptions) {
357
+ if (this.activeOptions !== null) {
351
358
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
359
+ } else {
360
+ // Purely declarative path: attributes drive the grid without an imperative bridge.
361
+ this.ensureController({
362
+ id: '__ui-grid-pending__',
363
+ data: [],
364
+ columnDefs: [],
365
+ ...this.attributeOptions,
366
+ } as VanillaGridOptions);
352
367
  }
353
368
  }
354
369
 
@@ -707,22 +722,63 @@ export class UiGridStandaloneElement extends HTMLElement {
707
722
  return;
708
723
  }
709
724
 
725
+ const previousSnapshot = this.snapshot;
726
+
710
727
  // Update the pipeline synchronously (cheap, no DOM work).
711
728
  this.skipNextRender = true;
712
729
  controller.refreshData(rows);
713
730
  this.skipNextRender = false;
714
731
 
732
+ const { mode, changedRowIds } = this.classifyDataRefresh(previousSnapshot, this.snapshot);
733
+ if (mode === null) {
734
+ return;
735
+ }
736
+
737
+ this.pendingDataRefreshMode = mode;
738
+ this.pendingPatchedRowIds = changedRowIds;
739
+
715
740
  // Schedule DOM patching on the next paint — latest call wins.
716
741
  if (this.dataFrame !== null) {
717
742
  cancelAnimationFrame(this.dataFrame);
718
743
  }
719
744
  this.dataFrame = requestAnimationFrame(() => {
720
- this.dataFrame = null;
721
- this.patchCells();
745
+ this.flushPendingDataRefresh();
722
746
  });
723
747
  }
724
748
 
725
- private patchCells(): void {
749
+ private flushPendingDataRefresh(): void {
750
+ this.dataFrame = null;
751
+
752
+ const mode = this.pendingDataRefreshMode;
753
+ if (mode === null) {
754
+ return;
755
+ }
756
+
757
+ if (Date.now() - this.lastScrollActivityAt < 80) {
758
+ this.dataFrame = requestAnimationFrame(() => {
759
+ this.flushPendingDataRefresh();
760
+ });
761
+ return;
762
+ }
763
+
764
+ const changedRowIds = this.pendingPatchedRowIds;
765
+ this.pendingDataRefreshMode = null;
766
+ this.pendingPatchedRowIds = null;
767
+
768
+ if (mode === 'patch') {
769
+ this.patchCells(changedRowIds ?? undefined);
770
+ return;
771
+ }
772
+
773
+ if (mode === 'virtual') {
774
+ this.renderVirtualBody();
775
+ return;
776
+ }
777
+
778
+ this.render();
779
+ }
780
+
781
+ private patchCells(changedRowIds?: ReadonlySet<string>): void {
726
782
  const snapshot = this.snapshot;
727
783
  if (!snapshot) {
728
784
  return;
@@ -735,12 +791,16 @@ export class UiGridStandaloneElement extends HTMLElement {
735
791
 
736
792
  // Build O(1) lookup maps from the current pipeline snapshot.
737
793
  const rowMap = new Map<string, GridRow>();
794
+ const rowIndexMap = new Map<string, number>();
738
795
  for (const row of snapshot.pipeline.visibleRows) {
739
796
  rowMap.set(row.id, row);
797
+ rowIndexMap.set(row.id, rowIndexMap.size);
740
798
  }
741
799
  const colMap = new Map<string, GridColumnDef>();
800
+ const templateMarkupMap = new Map<string, string | null>();
742
801
  for (const col of snapshot.visibleColumns) {
743
802
  colMap.set(col.name, col);
803
+ templateMarkupMap.set(col.name, this.getTemplateMarkup(this.cellSlotName(col)));
744
804
  }
745
805
 
746
806
  // Patch every rendered body cell in-place.
@@ -749,23 +809,24 @@ export class UiGridStandaloneElement extends HTMLElement {
749
809
  const rowId = cell.dataset['row'];
750
810
  const colName = cell.dataset['column'];
751
811
  if (!rowId || !colName) continue;
812
+ if (changedRowIds && !changedRowIds.has(rowId)) continue;
752
813
 
753
814
  const row = rowMap.get(rowId);
754
815
  const column = colMap.get(colName);
755
816
  if (!row || !column) continue;
817
+ if (cell.classList.contains('cell-editing')) continue;
756
818
 
757
- const cellShell = cell.querySelector<HTMLElement>('.cell-shell');
758
- if (!cellShell) continue;
819
+ const cellContent = cell.querySelector<HTMLElement>('.cell-content');
820
+ if (!cellContent) continue;
759
821
 
760
- const newContent = this.renderCellTemplate(row, column, 0);
761
- const toggles = cellShell.querySelectorAll<HTMLElement>('.row-toggle');
762
- if (toggles.length === 0) {
763
- cellShell.innerHTML = newContent;
764
- } else {
765
- const toggleHtml = Array.from(toggles)
766
- .map((t) => t.outerHTML)
767
- .join('');
768
- cellShell.innerHTML = toggleHtml + newContent;
822
+ const newContent = this.renderCellTemplateFromMarkup(
823
+ row,
824
+ column,
825
+ rowIndexMap.get(rowId) ?? 0,
826
+ templateMarkupMap.get(colName) ?? null,
827
+ );
828
+ if (cellContent.innerHTML !== newContent) {
829
+ cellContent.innerHTML = newContent;
769
830
  }
770
831
  }
771
832
  }
@@ -1203,7 +1264,9 @@ export class UiGridStandaloneElement extends HTMLElement {
1203
1264
  return;
1204
1265
  }
1205
1266
 
1267
+ this.lastScrollActivityAt = Date.now();
1206
1268
  this.scrollPosition = target.scrollTop;
1269
+ this.horizontalScrollPosition = target.scrollLeft;
1207
1270
  if (!this.snapshot?.pipeline.virtualizationEnabled) {
1208
1271
  return;
1209
1272
  }
@@ -1301,6 +1364,7 @@ export class UiGridStandaloneElement extends HTMLElement {
1301
1364
  const previousGridTable = root.querySelector<HTMLElement>('.grid-table');
1302
1365
  if (previousGridTable && !this.suppressScrollEvent) {
1303
1366
  this.scrollPosition = previousGridTable.scrollTop;
1367
+ this.horizontalScrollPosition = previousGridTable.scrollLeft;
1304
1368
  }
1305
1369
 
1306
1370
  if (!snapshot) {
@@ -1368,14 +1432,20 @@ export class UiGridStandaloneElement extends HTMLElement {
1368
1432
  : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
1369
1433
 
1370
1434
  const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : '';
1371
- const gridTableStyle = `${virtualizationEnabled ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1435
+ const hasViewportScroll = virtualizationEnabled || options.viewportHeight !== undefined;
1436
+ const gridTableStyle = `${hasViewportScroll ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1372
1437
 
1373
1438
  root.innerHTML = `<style>${GRID_CORE_CSS}</style>${slotRegistry}<section class="grid-frame ui-grid" role="grid" aria-label="${escapeHtml(options.title ?? 'Data grid')}"><div class="grid-table ui-grid-contents-wrapper" style="${gridTableStyle}"><div class="header-grid ui-grid-header ui-grid-header-canvas" style="grid-template-columns:${templateColumns}">${header}</div>${filterRow}${body}</div>${pagination}</section>`;
1374
1439
 
1375
1440
  const gridTable = root.querySelector<HTMLElement>('.grid-table');
1376
- if (gridTable && this.scrollPosition > 0 && virtualizationEnabled) {
1441
+ if (gridTable && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
1377
1442
  this.suppressScrollEvent = true;
1378
- gridTable.scrollTop = this.scrollPosition;
1443
+ if (this.scrollPosition > 0) {
1444
+ gridTable.scrollTop = this.scrollPosition;
1445
+ }
1446
+ if (this.horizontalScrollPosition > 0) {
1447
+ gridTable.scrollLeft = this.horizontalScrollPosition;
1448
+ }
1379
1449
  requestAnimationFrame(() => {
1380
1450
  this.suppressScrollEvent = false;
1381
1451
  });
@@ -1537,7 +1607,7 @@ export class UiGridStandaloneElement extends HTMLElement {
1537
1607
  .join(' ');
1538
1608
 
1539
1609
  const stickyStyle = pinOffset ? `${pinOffset.side}:${pinOffset.offset};` : '';
1540
- return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}${content}</div></div>`;
1610
+ return `<div class="${classes}" tabindex="0" data-row="${escapeHtml(rowId)}" data-column="${escapeHtml(columnName)}" style="${stickyStyle}"><div class="cell-shell" style="padding-inline-start:${escapeHtml(controller.cellIndent(row, column))}">${treeToggle}${expandToggle}<div class="cell-content">${content}</div></div></div>`;
1541
1611
  }
1542
1612
 
1543
1613
  private renderPagination(snapshot: GridControllerSnapshot): string {
@@ -1559,6 +1629,15 @@ export class UiGridStandaloneElement extends HTMLElement {
1559
1629
 
1560
1630
  private renderCellTemplate(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1561
1631
  const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
1632
+ return this.renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup);
1633
+ }
1634
+
1635
+ private renderCellTemplateFromMarkup(
1636
+ row: GridRow,
1637
+ column: GridColumnDef,
1638
+ displayIndex: number,
1639
+ templateMarkup: string | null,
1640
+ ): string {
1562
1641
  if (!templateMarkup) {
1563
1642
  return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? '')}</span>`;
1564
1643
  }
@@ -1624,6 +1703,101 @@ export class UiGridStandaloneElement extends HTMLElement {
1624
1703
  );
1625
1704
  }
1626
1705
 
1706
+ private classifyDataRefresh(
1707
+ previousSnapshot: GridControllerSnapshot | null,
1708
+ nextSnapshot: GridControllerSnapshot | null,
1709
+ ): { mode: 'patch' | 'virtual' | 'full' | null; changedRowIds: Set<string> | null } {
1710
+ if (!nextSnapshot) {
1711
+ return { mode: 'full', changedRowIds: null };
1712
+ }
1713
+
1714
+ if (!previousSnapshot) {
1715
+ return {
1716
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1717
+ changedRowIds: null,
1718
+ };
1719
+ }
1720
+
1721
+ if (previousSnapshot.visibleColumns.length !== nextSnapshot.visibleColumns.length) {
1722
+ return { mode: 'full', changedRowIds: null };
1723
+ }
1724
+
1725
+ for (let index = 0; index < previousSnapshot.visibleColumns.length; index += 1) {
1726
+ if (previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name) {
1727
+ return { mode: 'full', changedRowIds: null };
1728
+ }
1729
+ }
1730
+
1731
+ const previousWindow = this.getRenderedDisplayWindow(previousSnapshot);
1732
+ const nextWindow = this.getRenderedDisplayWindow(nextSnapshot);
1733
+ if (
1734
+ previousWindow.startIndex !== nextWindow.startIndex ||
1735
+ previousWindow.items.length !== nextWindow.items.length
1736
+ ) {
1737
+ return {
1738
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1739
+ changedRowIds: null,
1740
+ };
1741
+ }
1742
+
1743
+ const changedRowIds = new Set<string>();
1744
+ for (let index = 0; index < previousWindow.items.length; index += 1) {
1745
+ const previousItem = previousWindow.items[index];
1746
+ const nextItem = nextWindow.items[index];
1747
+
1748
+ if (!previousItem || !nextItem || previousItem.kind !== 'row' || nextItem.kind !== 'row') {
1749
+ return {
1750
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1751
+ changedRowIds: null,
1752
+ };
1753
+ }
1754
+
1755
+ if (previousItem.row.id !== nextItem.row.id) {
1756
+ return {
1757
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1758
+ changedRowIds: null,
1759
+ };
1760
+ }
1761
+
1762
+ if (previousItem.row.entity !== nextItem.row.entity) {
1763
+ changedRowIds.add(nextItem.row.id);
1764
+ }
1765
+ }
1766
+
1767
+ return {
1768
+ mode: changedRowIds.size > 0 ? 'patch' : null,
1769
+ changedRowIds: changedRowIds.size > 0 ? changedRowIds : null,
1770
+ };
1771
+ }
1772
+
1773
+ private getRenderedDisplayWindow(snapshot: GridControllerSnapshot): {
1774
+ startIndex: number;
1775
+ items: readonly DisplayItem[];
1776
+ } {
1777
+ if (!snapshot.pipeline.virtualizationEnabled) {
1778
+ return {
1779
+ startIndex: 0,
1780
+ items: snapshot.pipeline.displayItems,
1781
+ };
1782
+ }
1783
+
1784
+ const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1785
+ const viewportHeight = snapshot.options.viewportHeight ?? 560;
1786
+ const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1787
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1788
+ const overscan = 4;
1789
+ const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1790
+ const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1791
+
1792
+ return {
1793
+ startIndex,
1794
+ items: snapshot.pipeline.displayItems.slice(
1795
+ startIndex,
1796
+ Math.min(snapshot.pipeline.displayItems.length, startIndex + visibleCount),
1797
+ ),
1798
+ };
1799
+ }
1800
+
1627
1801
  private cellSlotName(column: GridColumnDef): string {
1628
1802
  return `cell-${column.name}`;
1629
1803
  }