@ornery/ui-grid-vanilla 0.1.8 → 0.1.10

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.
@@ -5,8 +5,11 @@ import {
5
5
  canGridMoveColumns,
6
6
  downloadGridCsvFile,
7
7
  exportCsvRows,
8
+ findNextGridCell,
8
9
  formatGridHeaderDisplayValue,
9
10
  getCellValue,
11
+ isGridNavigationKey,
12
+ isPrintableGridKey,
10
13
  sanitizeDownloadFilename,
11
14
  type DisplayItem,
12
15
  type GridColumnDef,
@@ -112,6 +115,7 @@ export class UiGridStandaloneElement extends HTMLElement {
112
115
  private draggedColumnName: string | null = null;
113
116
  private dropTargetColumnName: string | null = null;
114
117
  private scrollPosition = 0;
118
+ private horizontalScrollPosition = 0;
115
119
  private scrollFrame: number | null = null;
116
120
  private suppressScrollEvent = false;
117
121
  private lastVirtualStartIndex = -1;
@@ -121,6 +125,9 @@ export class UiGridStandaloneElement extends HTMLElement {
121
125
  private benchmarkAverage = '—';
122
126
  private skipNextRender = false;
123
127
  private dataFrame: number | null = null;
128
+ private pendingPatchedRowIds: Set<string> | null = null;
129
+ private pendingDataRefreshMode: 'patch' | 'virtual' | 'full' | null = null;
130
+ private lastScrollActivityAt = 0;
124
131
 
125
132
  static get observedAttributes(): string[] {
126
133
  return [
@@ -168,13 +175,16 @@ export class UiGridStandaloneElement extends HTMLElement {
168
175
  }
169
176
 
170
177
  get options(): GridOptions {
171
- return (
172
- this.activeOptions ?? {
173
- id: '__ui-grid-pending__',
174
- data: [],
175
- columnDefs: [],
176
- }
177
- );
178
+ if (this.activeOptions !== null) {
179
+ return this.buildEffectiveOptions(this.activeOptions);
180
+ }
181
+ // No imperative options set yet: safe defaults with attributes taking priority.
182
+ return {
183
+ id: '__ui-grid-pending__',
184
+ data: [],
185
+ columnDefs: [],
186
+ ...this.attributeOptions,
187
+ } as GridOptions;
178
188
  }
179
189
 
180
190
  set options(value: GridOptions) {
@@ -276,6 +286,10 @@ export class UiGridStandaloneElement extends HTMLElement {
276
286
  if (enableColumnMoving !== undefined)
277
287
  this.attributeOptions.enableColumnMoving = enableColumnMoving;
278
288
 
289
+ const enableColumnResizing = this.parseBooleanAttribute('enable-column-resizing');
290
+ if (enableColumnResizing !== undefined)
291
+ this.attributeOptions.enableColumnResizing = enableColumnResizing;
292
+
279
293
  const enableCellEdit = this.parseBooleanAttribute('enable-cell-edit');
280
294
  if (enableCellEdit !== undefined) this.attributeOptions.enableCellEdit = enableCellEdit;
281
295
 
@@ -347,8 +361,16 @@ export class UiGridStandaloneElement extends HTMLElement {
347
361
  this.attributeOptions.paginationPageSizes = paginationPageSizes;
348
362
 
349
363
  // Re-render with the merged options.
350
- if (this.activeOptions) {
364
+ if (this.activeOptions !== null) {
351
365
  this.ensureController(this.buildEffectiveOptions(this.activeOptions));
366
+ } else {
367
+ // Purely declarative path: attributes drive the grid without an imperative bridge.
368
+ this.ensureController({
369
+ id: '__ui-grid-pending__',
370
+ data: [],
371
+ columnDefs: [],
372
+ ...this.attributeOptions,
373
+ } as VanillaGridOptions);
352
374
  }
353
375
  }
354
376
 
@@ -565,6 +587,14 @@ export class UiGridStandaloneElement extends HTMLElement {
565
587
  this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
566
588
  }
567
589
 
590
+ get enableColumnResizing(): boolean {
591
+ return (this.options as any).enableColumnResizing ?? true;
592
+ }
593
+ set enableColumnResizing(value: boolean) {
594
+ this.activeOptions = { ...this.activeOptions, enableColumnResizing: value } as any;
595
+ this.ensureController(this.buildEffectiveOptions(this.activeOptions as VanillaGridOptions));
596
+ }
597
+
568
598
  get enableCellEdit(): boolean {
569
599
  return this.options.enableCellEdit ?? false;
570
600
  }
@@ -707,22 +737,63 @@ export class UiGridStandaloneElement extends HTMLElement {
707
737
  return;
708
738
  }
709
739
 
740
+ const previousSnapshot = this.snapshot;
741
+
710
742
  // Update the pipeline synchronously (cheap, no DOM work).
711
743
  this.skipNextRender = true;
712
744
  controller.refreshData(rows);
713
745
  this.skipNextRender = false;
714
746
 
747
+ const { mode, changedRowIds } = this.classifyDataRefresh(previousSnapshot, this.snapshot);
748
+ if (mode === null) {
749
+ return;
750
+ }
751
+
752
+ this.pendingDataRefreshMode = mode;
753
+ this.pendingPatchedRowIds = changedRowIds;
754
+
715
755
  // Schedule DOM patching on the next paint — latest call wins.
716
756
  if (this.dataFrame !== null) {
717
757
  cancelAnimationFrame(this.dataFrame);
718
758
  }
719
759
  this.dataFrame = requestAnimationFrame(() => {
720
- this.dataFrame = null;
721
- this.patchCells();
760
+ this.flushPendingDataRefresh();
722
761
  });
723
762
  }
724
763
 
725
- private patchCells(): void {
764
+ private flushPendingDataRefresh(): void {
765
+ this.dataFrame = null;
766
+
767
+ const mode = this.pendingDataRefreshMode;
768
+ if (mode === null) {
769
+ return;
770
+ }
771
+
772
+ if (Date.now() - this.lastScrollActivityAt < 80) {
773
+ this.dataFrame = requestAnimationFrame(() => {
774
+ this.flushPendingDataRefresh();
775
+ });
776
+ return;
777
+ }
778
+
779
+ const changedRowIds = this.pendingPatchedRowIds;
780
+ this.pendingDataRefreshMode = null;
781
+ this.pendingPatchedRowIds = null;
782
+
783
+ if (mode === 'patch') {
784
+ this.patchCells(changedRowIds ?? undefined);
785
+ return;
786
+ }
787
+
788
+ if (mode === 'virtual') {
789
+ this.renderVirtualBody();
790
+ return;
791
+ }
792
+
793
+ this.render();
794
+ }
795
+
796
+ private patchCells(changedRowIds?: ReadonlySet<string>): void {
726
797
  const snapshot = this.snapshot;
727
798
  if (!snapshot) {
728
799
  return;
@@ -735,12 +806,16 @@ export class UiGridStandaloneElement extends HTMLElement {
735
806
 
736
807
  // Build O(1) lookup maps from the current pipeline snapshot.
737
808
  const rowMap = new Map<string, GridRow>();
809
+ const rowIndexMap = new Map<string, number>();
738
810
  for (const row of snapshot.pipeline.visibleRows) {
739
811
  rowMap.set(row.id, row);
812
+ rowIndexMap.set(row.id, rowIndexMap.size);
740
813
  }
741
814
  const colMap = new Map<string, GridColumnDef>();
815
+ const templateMarkupMap = new Map<string, string | null>();
742
816
  for (const col of snapshot.visibleColumns) {
743
817
  colMap.set(col.name, col);
818
+ templateMarkupMap.set(col.name, this.getTemplateMarkup(this.cellSlotName(col)));
744
819
  }
745
820
 
746
821
  // Patch every rendered body cell in-place.
@@ -749,23 +824,24 @@ export class UiGridStandaloneElement extends HTMLElement {
749
824
  const rowId = cell.dataset['row'];
750
825
  const colName = cell.dataset['column'];
751
826
  if (!rowId || !colName) continue;
827
+ if (changedRowIds && !changedRowIds.has(rowId)) continue;
752
828
 
753
829
  const row = rowMap.get(rowId);
754
830
  const column = colMap.get(colName);
755
831
  if (!row || !column) continue;
832
+ if (cell.classList.contains('cell-editing')) continue;
756
833
 
757
- const cellShell = cell.querySelector<HTMLElement>('.cell-shell');
758
- if (!cellShell) continue;
834
+ const cellContent = cell.querySelector<HTMLElement>('.cell-content');
835
+ if (!cellContent) continue;
759
836
 
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;
837
+ const newContent = this.renderCellTemplateFromMarkup(
838
+ row,
839
+ column,
840
+ rowIndexMap.get(rowId) ?? 0,
841
+ templateMarkupMap.get(colName) ?? null,
842
+ );
843
+ if (cellContent.innerHTML !== newContent) {
844
+ cellContent.innerHTML = newContent;
769
845
  }
770
846
  }
771
847
  }
@@ -1023,12 +1099,65 @@ export class UiGridStandaloneElement extends HTMLElement {
1023
1099
  }
1024
1100
  });
1025
1101
 
1102
+ root.addEventListener('mousedown', (event) => {
1103
+ const target = event.target as HTMLElement | null;
1104
+ if (!target || !this.controller) return;
1105
+
1106
+ const resizer = target.closest<HTMLElement>('.column-resizer[data-column]');
1107
+ if (!resizer || !this.controller.canResizeColumns()) return;
1108
+
1109
+ const columnName = resizer.dataset['column'];
1110
+ if (!columnName) return;
1111
+
1112
+ const headerCell = resizer.closest<HTMLElement>('.header-cell');
1113
+ if (!headerCell) return;
1114
+
1115
+ event.preventDefault();
1116
+ event.stopPropagation();
1117
+
1118
+ const startX = (event as MouseEvent).clientX;
1119
+ const startWidth = headerCell.getBoundingClientRect().width;
1120
+ let lastWidth = startWidth;
1121
+
1122
+ const handleMove = (moveEvent: MouseEvent): void => {
1123
+ lastWidth = Math.max(88, startWidth + (moveEvent.clientX - startX));
1124
+
1125
+ // Write directly to DOM — skip full refresh while dragging.
1126
+ const newTemplate = this.controller!.buildTemplateColumnsWithOverride(columnName, lastWidth);
1127
+ const root = this.shadowRoot ?? this;
1128
+ (root as ShadowRoot | HTMLElement).querySelectorAll<HTMLElement>('.header-grid, .filter-grid, .body-grid').forEach((el) => {
1129
+ el.style.gridTemplateColumns = newTemplate;
1130
+ });
1131
+ };
1132
+
1133
+ const handleUp = (): void => {
1134
+ window.removeEventListener('mousemove', handleMove);
1135
+ window.removeEventListener('mouseup', handleUp);
1136
+ // Commit final width once — triggers one full refresh.
1137
+ this.controller!.setColumnWidthOverride(columnName, lastWidth);
1138
+ };
1139
+
1140
+ window.addEventListener('mousemove', handleMove);
1141
+ window.addEventListener('mouseup', handleUp);
1142
+ });
1143
+
1026
1144
  root.addEventListener('dblclick', (event) => {
1027
1145
  const target = event.target as HTMLElement | null;
1028
1146
  if (!target || !this.controller) {
1029
1147
  return;
1030
1148
  }
1031
1149
 
1150
+ const resizer = target.closest<HTMLElement>('.column-resizer[data-column]');
1151
+ if (resizer && this.controller.canResizeColumns()) {
1152
+ const columnName = resizer.dataset['column'];
1153
+ if (columnName) {
1154
+ event.preventDefault();
1155
+ event.stopPropagation();
1156
+ this.controller.setColumnWidthOverride(columnName, this.measureAutoColumnWidth(columnName));
1157
+ return;
1158
+ }
1159
+ }
1160
+
1032
1161
  const cell = target.closest<HTMLElement>('.body-cell');
1033
1162
  if (!cell) {
1034
1163
  return;
@@ -1203,7 +1332,9 @@ export class UiGridStandaloneElement extends HTMLElement {
1203
1332
  return;
1204
1333
  }
1205
1334
 
1335
+ this.lastScrollActivityAt = Date.now();
1206
1336
  this.scrollPosition = target.scrollTop;
1337
+ this.horizontalScrollPosition = target.scrollLeft;
1207
1338
  if (!this.snapshot?.pipeline.virtualizationEnabled) {
1208
1339
  return;
1209
1340
  }
@@ -1301,6 +1432,7 @@ export class UiGridStandaloneElement extends HTMLElement {
1301
1432
  const previousGridTable = root.querySelector<HTMLElement>('.grid-table');
1302
1433
  if (previousGridTable && !this.suppressScrollEvent) {
1303
1434
  this.scrollPosition = previousGridTable.scrollTop;
1435
+ this.horizontalScrollPosition = previousGridTable.scrollLeft;
1304
1436
  }
1305
1437
 
1306
1438
  if (!snapshot) {
@@ -1368,14 +1500,20 @@ export class UiGridStandaloneElement extends HTMLElement {
1368
1500
  : `<div class="empty-state ui-grid-no-row-overlay"><strong>${escapeHtml(options.emptyMessage ?? labels.emptyHeading)}</strong><p>${escapeHtml(labels.emptyDescription)}</p></div>`;
1369
1501
 
1370
1502
  const pagination = paginationEnabled && showPagination ? this.renderPagination(snapshot) : '';
1371
- const gridTableStyle = `${virtualizationEnabled ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1503
+ const hasViewportScroll = virtualizationEnabled || options.viewportHeight !== undefined;
1504
+ const gridTableStyle = `${hasViewportScroll ? `height:${viewportHeight}px;overflow-y:auto;` : ''}--ui-grid-header-sticky-top:${headerStickyTop}px;`;
1372
1505
 
1373
1506
  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
1507
 
1375
1508
  const gridTable = root.querySelector<HTMLElement>('.grid-table');
1376
- if (gridTable && this.scrollPosition > 0 && virtualizationEnabled) {
1509
+ if (gridTable && (this.scrollPosition > 0 || this.horizontalScrollPosition > 0)) {
1377
1510
  this.suppressScrollEvent = true;
1378
- gridTable.scrollTop = this.scrollPosition;
1511
+ if (this.scrollPosition > 0) {
1512
+ gridTable.scrollTop = this.scrollPosition;
1513
+ }
1514
+ if (this.horizontalScrollPosition > 0) {
1515
+ gridTable.scrollLeft = this.horizontalScrollPosition;
1516
+ }
1379
1517
  requestAnimationFrame(() => {
1380
1518
  this.suppressScrollEvent = false;
1381
1519
  });
@@ -1450,9 +1588,13 @@ export class UiGridStandaloneElement extends HTMLElement {
1450
1588
  const pinLabel = isPinned
1451
1589
  ? (this.snapshot?.labels.unpin ?? 'Unpin')
1452
1590
  : (this.snapshot?.labels.pinColumn ?? 'Pin');
1591
+ const canResize = controller.canResizeColumns();
1453
1592
  const headerValue = escapeHtml(formatGridHeaderDisplayValue(buildGridHeaderContext(column)));
1593
+ const resizerHtml = canResize
1594
+ ? `<button type="button" class="column-resizer" data-action="resize" data-column="${escapeHtml(column.name)}" aria-label="Resize ${escapeHtml(headerValue)} column" title="Drag to resize, double-click to auto fit"></button>`
1595
+ : '';
1454
1596
 
1455
- return `<div class="${classes}" data-column="${escapeHtml(column.name)}" ${draggable} style="${stickyStyle}"><span class="header-label">${headerValue}</span><span class="header-actions">${sortEnabled ? `<button type="button" class="header-action" data-action="sort" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(sortLabel)}" ${canSort ? '' : 'disabled'}>${this.renderControlIcon(sortIconKey)}<span class="sr-only">${escapeHtml(sortLabel)}</span></button>` : ''}${groupingEnabled ? `<button type="button" class="chip-action${controller.isColumnGrouped(column) ? ' chip-action-active' : ''}" data-action="group" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(groupingLabel)}" ${canGroup ? '' : 'disabled'}>${this.renderControlIcon('group')}<span class="sr-only">${escapeHtml(groupingLabel)}</span></button>` : ''}${canPin ? `<div class="pin-control${this.openPinMenuColumn === column.name ? ' pin-control-open' : ''}"><button type="button" class="chip-action pin-trigger${isPinned ? ' chip-action-active' : ''}" data-action="pin-trigger" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(pinLabel)}">${this.renderControlIcon('pin')}<span class="sr-only">${escapeHtml(pinLabel)}</span></button><div class="pin-menu" role="menu" aria-label="${escapeHtml(pinLabel)}"><button type="button" class="pin-menu-action" data-action="pin-left" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(this.snapshot?.labels.pinLeft ?? 'Pin left')}">${this.renderIconWithClass('control-icon', 'pinLeft')}<span class="sr-only">${escapeHtml(this.snapshot?.labels.pinLeft ?? 'Pin left')}</span></button><button type="button" class="pin-menu-action" data-action="pin-right" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(this.snapshot?.labels.pinRight ?? 'Pin right')}">${this.renderIconWithClass('control-icon', 'pinRight')}<span class="sr-only">${escapeHtml(this.snapshot?.labels.pinRight ?? 'Pin right')}</span></button></div></div>` : ''}</span></div>`;
1597
+ return `<div class="${classes}" data-column="${escapeHtml(column.name)}" ${draggable} style="${stickyStyle}"><span class="header-label">${headerValue}</span><span class="header-actions">${sortEnabled ? `<button type="button" class="header-action" data-action="sort" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(sortLabel)}" ${canSort ? '' : 'disabled'}>${this.renderControlIcon(sortIconKey)}<span class="sr-only">${escapeHtml(sortLabel)}</span></button>` : ''}${groupingEnabled ? `<button type="button" class="chip-action${controller.isColumnGrouped(column) ? ' chip-action-active' : ''}" data-action="group" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(groupingLabel)}" ${canGroup ? '' : 'disabled'}>${this.renderControlIcon('group')}<span class="sr-only">${escapeHtml(groupingLabel)}</span></button>` : ''}${canPin ? `<div class="pin-control${this.openPinMenuColumn === column.name ? ' pin-control-open' : ''}"><button type="button" class="chip-action pin-trigger${isPinned ? ' chip-action-active' : ''}" data-action="pin-trigger" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(pinLabel)}">${this.renderControlIcon('pin')}<span class="sr-only">${escapeHtml(pinLabel)}</span></button><div class="pin-menu" role="menu" aria-label="${escapeHtml(pinLabel)}"><button type="button" class="pin-menu-action" data-action="pin-left" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(this.snapshot?.labels.pinLeft ?? 'Pin left')}">${this.renderIconWithClass('control-icon', 'pinLeft')}<span class="sr-only">${escapeHtml(this.snapshot?.labels.pinLeft ?? 'Pin left')}</span></button><button type="button" class="pin-menu-action" data-action="pin-right" data-column="${escapeHtml(column.name)}" aria-label="${escapeHtml(this.snapshot?.labels.pinRight ?? 'Pin right')}">${this.renderIconWithClass('control-icon', 'pinRight')}<span class="sr-only">${escapeHtml(this.snapshot?.labels.pinRight ?? 'Pin right')}</span></button></div></div>` : ''}</span>${resizerHtml}</div>`;
1456
1598
  }
1457
1599
 
1458
1600
  private renderFilterCell(column: GridColumnDef): string {
@@ -1472,6 +1614,24 @@ export class UiGridStandaloneElement extends HTMLElement {
1472
1614
  return `<label class="${classes}" style="${stickyStyle}"><input class="ui-grid-filter-input" data-role="filter" data-column="${escapeHtml(column.name)}" placeholder="${escapeHtml(controller.filterPlaceholder(column))}" value="${escapeHtml(value)}" ${canFilter ? '' : 'disabled'}></label>`;
1473
1615
  }
1474
1616
 
1617
+ private measureAutoColumnWidth(columnName: string): number {
1618
+ const root = this.shadowRoot;
1619
+ if (root == null) return 176;
1620
+ const escaped = CSS.escape ? CSS.escape(columnName) : columnName.replace(/([\\".#:[\](){}+~> ])/g, '\\$1');
1621
+ const selectors = [
1622
+ `.header-cell[data-column="${escaped}"]`,
1623
+ `.filter-cell[data-column="${escaped}"]`,
1624
+ `.body-cell[data-column="${escaped}"] .cell-shell`,
1625
+ ];
1626
+ let maxWidth = 0;
1627
+ for (const selector of selectors) {
1628
+ for (const el of root.querySelectorAll<HTMLElement>(selector)) {
1629
+ maxWidth = Math.max(maxWidth, el.scrollWidth);
1630
+ }
1631
+ }
1632
+ return maxWidth + 12;
1633
+ }
1634
+
1475
1635
  private renderDisplayItem(item: DisplayItem, displayIndex: number): string {
1476
1636
  if (!this.snapshot || !this.controller) {
1477
1637
  return '';
@@ -1537,7 +1697,7 @@ export class UiGridStandaloneElement extends HTMLElement {
1537
1697
  .join(' ');
1538
1698
 
1539
1699
  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>`;
1700
+ 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
1701
  }
1542
1702
 
1543
1703
  private renderPagination(snapshot: GridControllerSnapshot): string {
@@ -1559,6 +1719,15 @@ export class UiGridStandaloneElement extends HTMLElement {
1559
1719
 
1560
1720
  private renderCellTemplate(row: GridRow, column: GridColumnDef, displayIndex: number): string {
1561
1721
  const templateMarkup = this.getTemplateMarkup(this.cellSlotName(column));
1722
+ return this.renderCellTemplateFromMarkup(row, column, displayIndex, templateMarkup);
1723
+ }
1724
+
1725
+ private renderCellTemplateFromMarkup(
1726
+ row: GridRow,
1727
+ column: GridColumnDef,
1728
+ displayIndex: number,
1729
+ templateMarkup: string | null,
1730
+ ): string {
1562
1731
  if (!templateMarkup) {
1563
1732
  return `<span class="cell-value">${escapeHtml(this.controller?.displayValue(row, column) ?? '')}</span>`;
1564
1733
  }
@@ -1624,6 +1793,101 @@ export class UiGridStandaloneElement extends HTMLElement {
1624
1793
  );
1625
1794
  }
1626
1795
 
1796
+ private classifyDataRefresh(
1797
+ previousSnapshot: GridControllerSnapshot | null,
1798
+ nextSnapshot: GridControllerSnapshot | null,
1799
+ ): { mode: 'patch' | 'virtual' | 'full' | null; changedRowIds: Set<string> | null } {
1800
+ if (!nextSnapshot) {
1801
+ return { mode: 'full', changedRowIds: null };
1802
+ }
1803
+
1804
+ if (!previousSnapshot) {
1805
+ return {
1806
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1807
+ changedRowIds: null,
1808
+ };
1809
+ }
1810
+
1811
+ if (previousSnapshot.visibleColumns.length !== nextSnapshot.visibleColumns.length) {
1812
+ return { mode: 'full', changedRowIds: null };
1813
+ }
1814
+
1815
+ for (let index = 0; index < previousSnapshot.visibleColumns.length; index += 1) {
1816
+ if (previousSnapshot.visibleColumns[index]?.name !== nextSnapshot.visibleColumns[index]?.name) {
1817
+ return { mode: 'full', changedRowIds: null };
1818
+ }
1819
+ }
1820
+
1821
+ const previousWindow = this.getRenderedDisplayWindow(previousSnapshot);
1822
+ const nextWindow = this.getRenderedDisplayWindow(nextSnapshot);
1823
+ if (
1824
+ previousWindow.startIndex !== nextWindow.startIndex ||
1825
+ previousWindow.items.length !== nextWindow.items.length
1826
+ ) {
1827
+ return {
1828
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1829
+ changedRowIds: null,
1830
+ };
1831
+ }
1832
+
1833
+ const changedRowIds = new Set<string>();
1834
+ for (let index = 0; index < previousWindow.items.length; index += 1) {
1835
+ const previousItem = previousWindow.items[index];
1836
+ const nextItem = nextWindow.items[index];
1837
+
1838
+ if (!previousItem || !nextItem || previousItem.kind !== 'row' || nextItem.kind !== 'row') {
1839
+ return {
1840
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1841
+ changedRowIds: null,
1842
+ };
1843
+ }
1844
+
1845
+ if (previousItem.row.id !== nextItem.row.id) {
1846
+ return {
1847
+ mode: nextSnapshot.pipeline.virtualizationEnabled ? 'virtual' : 'full',
1848
+ changedRowIds: null,
1849
+ };
1850
+ }
1851
+
1852
+ if (previousItem.row.entity !== nextItem.row.entity) {
1853
+ changedRowIds.add(nextItem.row.id);
1854
+ }
1855
+ }
1856
+
1857
+ return {
1858
+ mode: changedRowIds.size > 0 ? 'patch' : null,
1859
+ changedRowIds: changedRowIds.size > 0 ? changedRowIds : null,
1860
+ };
1861
+ }
1862
+
1863
+ private getRenderedDisplayWindow(snapshot: GridControllerSnapshot): {
1864
+ startIndex: number;
1865
+ items: readonly DisplayItem[];
1866
+ } {
1867
+ if (!snapshot.pipeline.virtualizationEnabled) {
1868
+ return {
1869
+ startIndex: 0,
1870
+ items: snapshot.pipeline.displayItems,
1871
+ };
1872
+ }
1873
+
1874
+ const stickyChromeHeight = this.measuredHeaderStickyHeight + this.measuredFilterStickyHeight;
1875
+ const viewportHeight = snapshot.options.viewportHeight ?? 560;
1876
+ const bodyViewportHeight = Math.max(snapshot.rowSize, viewportHeight - stickyChromeHeight);
1877
+ const bodyScrollTop = Math.max(0, this.scrollPosition - stickyChromeHeight);
1878
+ const overscan = 4;
1879
+ const startIndex = Math.max(0, Math.floor(bodyScrollTop / snapshot.rowSize) - overscan);
1880
+ const visibleCount = Math.ceil(bodyViewportHeight / snapshot.rowSize) + overscan * 2;
1881
+
1882
+ return {
1883
+ startIndex,
1884
+ items: snapshot.pipeline.displayItems.slice(
1885
+ startIndex,
1886
+ Math.min(snapshot.pipeline.displayItems.length, startIndex + visibleCount),
1887
+ ),
1888
+ };
1889
+ }
1890
+
1627
1891
  private cellSlotName(column: GridColumnDef): string {
1628
1892
  return `cell-${column.name}`;
1629
1893
  }