@svgrid/grid 1.2.3 → 1.2.4

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/menus.js CHANGED
@@ -221,12 +221,14 @@ export function createMenus(ctx) {
221
221
  ...prev,
222
222
  pageIndex: Math.max((prev?.pageIndex ?? 0) + delta, 0),
223
223
  }));
224
+ ctx.scrollContainer?.scrollTo({ top: 0 });
224
225
  }
225
226
  function goToPage(pageIndex) {
226
227
  ctx.grid.setPagination((prev) => ({
227
228
  ...prev,
228
229
  pageIndex: Math.max(0, pageIndex),
229
230
  }));
231
+ ctx.scrollContainer?.scrollTo({ top: 0 });
230
232
  }
231
233
  function setPageSize(pageSize) {
232
234
  ctx.grid.setPagination((prev) => {
@@ -281,9 +283,15 @@ export function createMenus(ctx) {
281
283
  const rowModel = ctx.allRows[rowIndex];
282
284
  const row = rowModel?.original ?? null;
283
285
  const rowId = rowModel?.id ?? String(rowIndex);
286
+ // Reserve ~280px for the menu body. If the click is in the bottom third,
287
+ // flip the menu above the cursor so it stays fully in the viewport.
288
+ const menuHeight = 280;
289
+ const y = event.clientY + menuHeight > window.innerHeight
290
+ ? Math.max(8, event.clientY - menuHeight)
291
+ : event.clientY;
284
292
  ctx.contextMenuPos = {
285
293
  x: clampMenuX(event.clientX, 220),
286
- y: Math.max(8, Math.min(event.clientY, window.innerHeight - 16)),
294
+ y,
287
295
  };
288
296
  ctx.contextMenuFor = { rowIndex, colIndex, columnId, rowId, row };
289
297
  }
@@ -61,7 +61,7 @@ export function createScrollSync(ctx) {
61
61
  const container = event.currentTarget;
62
62
  if (!container)
63
63
  return;
64
- if (ctx.columnMenuFor || ctx.operatorMenuFor)
64
+ if (ctx.columnMenuFor || ctx.operatorMenuFor || ctx.contextMenuFor)
65
65
  ctx.closeMenus();
66
66
  scheduleScrollSync(container.scrollTop, container.scrollLeft);
67
67
  // Mirror horizontal scroll to any aligned grids in the same group.
@@ -148,6 +148,7 @@ class SvGridScrollbarElement extends ScrollbarBase {
148
148
  this.valueStart = 0;
149
149
  this.holdTimer = null;
150
150
  this.resizeObserver = null;
151
+ this.resizeFrame = 0;
151
152
  this.onArrowStartPointerDown = (event) => {
152
153
  event.preventDefault();
153
154
  this.startHold(-1);
@@ -225,12 +226,26 @@ class SvGridScrollbarElement extends ScrollbarBase {
225
226
  btn.addEventListener('pointerleave', this.stopHold);
226
227
  btn.addEventListener('pointercancel', this.stopHold);
227
228
  }
228
- this.resizeObserver = new ResizeObserver(() => this.render());
229
+ // Coalesce resize notifications into a single next-frame render so the
230
+ // observer callback never re-lays-out the observed element within the same
231
+ // delivery cycle (which is what triggers the benign but noisy
232
+ // "ResizeObserver loop completed with undelivered notifications" warning).
233
+ this.resizeObserver = new ResizeObserver(() => {
234
+ if (this.resizeFrame)
235
+ return;
236
+ this.resizeFrame = requestAnimationFrame(() => {
237
+ this.resizeFrame = 0;
238
+ this.render();
239
+ });
240
+ });
229
241
  this.resizeObserver.observe(this);
230
242
  this.render();
231
243
  }
232
244
  disconnectedCallback() {
233
245
  this.stopHold();
246
+ if (this.resizeFrame)
247
+ cancelAnimationFrame(this.resizeFrame);
248
+ this.resizeFrame = 0;
234
249
  this.resizeObserver?.disconnect();
235
250
  this.resizeObserver = null;
236
251
  document.removeEventListener('pointermove', this.onPointerMove);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svgrid/grid",
3
- "version": "1.2.3",
3
+ "version": "1.2.4",
4
4
  "description": "SvGrid - a headless-first, Svelte 5-native data grid engine and render component.",
5
5
  "author": "jQWidgets <boikom@jqwidgets.com>",
6
6
  "license": "MIT",
@@ -216,6 +216,35 @@ function getMaxDomScrollHeight(): number {
216
216
  return detectedMaxDomHeight;
217
217
  }
218
218
 
219
+ /**
220
+ * Observe an element's size, but run the callback on the next animation frame
221
+ * and coalesce bursts into a single call. This is what keeps the benign but
222
+ * noisy "ResizeObserver loop completed with undelivered notifications" warning
223
+ * out of the console: the browser emits it when an observer callback
224
+ * synchronously mutates layout in a way that would require another notification
225
+ * within the same delivery cycle - which our callbacks do (they bump reactive
226
+ * versions / remeasure, driving a re-layout of the observed element). Deferring
227
+ * the work to the next frame lets the current delivery finish cleanly, so the
228
+ * loop never spans a single cycle. This is especially visible when swapping the
229
+ * whole grid (e.g. switching demos), which remounts everything at once.
230
+ * Returns a disconnect function suitable for an $effect cleanup.
231
+ */
232
+ function observeSizeRaf(el: Element, cb: () => void): () => void {
233
+ let frame = 0;
234
+ const observer = new ResizeObserver(() => {
235
+ if (frame) return;
236
+ frame = requestAnimationFrame(() => {
237
+ frame = 0;
238
+ cb();
239
+ });
240
+ });
241
+ observer.observe(el);
242
+ return () => {
243
+ if (frame) cancelAnimationFrame(frame);
244
+ observer.disconnect();
245
+ };
246
+ }
247
+
219
248
  /**
220
249
  * SvGrid controller. The component's entire reactive core - every $state,
221
250
  * $derived, $effect and handler - lives here so SvGrid.svelte can stay a thin
@@ -1057,6 +1086,17 @@ export function createSvGridController<
1057
1086
  const start = pageIndex * pageSize;
1058
1087
  return rows.slice(start, start + pageSize);
1059
1088
  });
1089
+
1090
+ // When a filter reduces the dataset, the stored pageIndex can point beyond
1091
+ // the last valid page. Reset to page 0 so the grid never shows a blank body.
1092
+ $effect(() => {
1093
+ if (!paginationEnabled) return;
1094
+ const { pageIndex, pageSize } = paginationState;
1095
+ const pageCount = Math.ceil(allRowsBeforePagination.length / pageSize);
1096
+ if (pageCount > 0 && pageIndex >= pageCount) {
1097
+ grid.setPagination({ pageIndex: 0, pageSize });
1098
+ }
1099
+ });
1060
1100
  const rowSelectionState = $derived.by(() => {
1061
1101
  gridStateVersion;
1062
1102
  return grid.getState().rowSelection ?? {};
@@ -1356,14 +1396,45 @@ export function createSvGridController<
1356
1396
  return columnVirtualizer.getTotalSize();
1357
1397
  });
1358
1398
  const renderedColumnItems = $derived.by(() => {
1359
- if (columnVirtualizationEnabled) return virtualColumns;
1360
- let start = 0;
1361
- return allColumns.map((column, index) => {
1362
- const size = getColumnWidth(column.id);
1363
- const item = { index, key: index, size, start, end: start + size };
1364
- start += size;
1365
- return item;
1366
- });
1399
+ if (!columnVirtualizationEnabled) {
1400
+ let start = 0;
1401
+ return allColumns.map((column, index) => {
1402
+ const size = getColumnWidth(column.id);
1403
+ const item = { index, key: index, size, start, end: start + size };
1404
+ start += size;
1405
+ return item;
1406
+ });
1407
+ }
1408
+ // Pinned columns are position:sticky, so they only stay pinned while their
1409
+ // cell is in the DOM. Plain column virtualization drops them once they leave
1410
+ // the scroll window, and the pinned column vanishes. Because allColumns is
1411
+ // ordered [pinnedLeft, unpinned, pinnedRight], we keep the rendered window
1412
+ // CONTIGUOUS from the pinned-left prefix (index 0) through the pinned-right
1413
+ // suffix (last index) whenever those exist. The pinned cells are then always
1414
+ // rendered - the existing single-spacer layout positions everything, so no
1415
+ // markup changes are needed. (Cost: with a pinned side, the columns between
1416
+ // that edge and the window are also rendered; negligible for typical grids,
1417
+ // and correctness beats shaving a few off-screen cells.)
1418
+ const window = virtualColumns;
1419
+ const hasLeft = columnPinning.left.length > 0;
1420
+ const hasRight = columnPinning.right.length > 0;
1421
+ if ((!hasLeft && !hasRight) || window.length === 0) return window;
1422
+
1423
+ const firstIdx = window[0]!.index;
1424
+ const lastIdx = window[window.length - 1]!.index;
1425
+ const startIndex = hasLeft ? 0 : firstIdx;
1426
+ const endIndex = hasRight ? allColumns.length - 1 : lastIdx;
1427
+ if (startIndex === firstIdx && endIndex === lastIdx) return window;
1428
+
1429
+ const items: Array<{ index: number; key: number; size: number; start: number; end: number }> = [];
1430
+ let offset = 0;
1431
+ for (let i = 0; i < startIndex; i += 1) offset += getColumnWidth(allColumns[i]!.id);
1432
+ for (let i = startIndex; i <= endIndex; i += 1) {
1433
+ const size = getColumnWidth(allColumns[i]!.id);
1434
+ items.push({ index: i, key: i, size, start: offset, end: offset + size });
1435
+ offset += size;
1436
+ }
1437
+ return items;
1367
1438
  });
1368
1439
  const renderedColumns = $derived.by(() =>
1369
1440
  renderedColumnItems
@@ -1461,11 +1532,9 @@ export function createSvGridController<
1461
1532
  $effect(() => {
1462
1533
  if (!theadEl) return;
1463
1534
  headerHeight = theadEl.offsetHeight;
1464
- const observer = new ResizeObserver(() => {
1535
+ return observeSizeRaf(theadEl, () => {
1465
1536
  headerHeight = theadEl?.offsetHeight ?? 0;
1466
1537
  });
1467
- observer.observe(theadEl);
1468
- return () => observer.disconnect();
1469
1538
  });
1470
1539
 
1471
1540
  // Bump scrollVersion when the table's layout size changes so scrollbar
@@ -1473,11 +1542,9 @@ export function createSvGridController<
1473
1542
  // after column resize / show-hide / add-remove.
1474
1543
  $effect(() => {
1475
1544
  if (!gridRootEl) return;
1476
- const observer = new ResizeObserver(() => {
1545
+ return observeSizeRaf(gridRootEl, () => {
1477
1546
  scrollVersion += 1;
1478
1547
  });
1479
- observer.observe(gridRootEl);
1480
- return () => observer.disconnect();
1481
1548
  });
1482
1549
 
1483
1550
  $effect(() => {
@@ -1596,12 +1663,10 @@ export function createSvGridController<
1596
1663
 
1597
1664
  $effect(() => {
1598
1665
  if (!scrollContainer) return;
1599
- const observer = new ResizeObserver(() => {
1666
+ return observeSizeRaf(scrollContainer, () => {
1600
1667
  viewportVersion += 1;
1601
1668
  if (!hasMeasured) hasMeasured = true;
1602
1669
  });
1603
- observer.observe(scrollContainer);
1604
- return () => observer.disconnect();
1605
1670
  });
1606
1671
 
1607
1672
  $effect(() => {
package/src/a11y.ts CHANGED
@@ -50,7 +50,7 @@ export function getGridCellA11yProps(input: GridCellA11yInput = {}) {
50
50
  ...(id ? { id } : {}),
51
51
  ...(colIndex !== undefined ? { 'aria-colindex': colIndex } : {}),
52
52
  ...(rowIndex !== undefined ? { 'aria-rowindex': rowIndex } : {}),
53
- ...(selected ? { 'aria-selected': true } : {}),
53
+ 'aria-selected': selected,
54
54
  } as const
55
55
  }
56
56
 
package/src/menus.ts CHANGED
@@ -373,6 +373,7 @@ export function createMenus<
373
373
  ...prev,
374
374
  pageIndex: Math.max((prev?.pageIndex ?? 0) + delta, 0),
375
375
  }));
376
+ ctx.scrollContainer?.scrollTo({ top: 0 });
376
377
  }
377
378
 
378
379
  function goToPage(pageIndex: number) {
@@ -380,6 +381,7 @@ export function createMenus<
380
381
  ...prev,
381
382
  pageIndex: Math.max(0, pageIndex),
382
383
  }));
384
+ ctx.scrollContainer?.scrollTo({ top: 0 });
383
385
  }
384
386
 
385
387
  function setPageSize(pageSize: number) {
@@ -447,9 +449,15 @@ export function createMenus<
447
449
  const rowModel = ctx.allRows[rowIndex];
448
450
  const row = rowModel?.original ?? null;
449
451
  const rowId = rowModel?.id ?? String(rowIndex);
452
+ // Reserve ~280px for the menu body. If the click is in the bottom third,
453
+ // flip the menu above the cursor so it stays fully in the viewport.
454
+ const menuHeight = 280;
455
+ const y = event.clientY + menuHeight > window.innerHeight
456
+ ? Math.max(8, event.clientY - menuHeight)
457
+ : event.clientY;
450
458
  ctx.contextMenuPos = {
451
459
  x: clampMenuX(event.clientX, 220),
452
- y: Math.max(8, Math.min(event.clientY, window.innerHeight - 16)),
460
+ y,
453
461
  };
454
462
  ctx.contextMenuFor = { rowIndex, colIndex, columnId, rowId, row };
455
463
  }
@@ -191,7 +191,7 @@ export function createScrollSync<
191
191
  function onBodyScroll(event: Event) {
192
192
  const container = event.currentTarget as HTMLDivElement | null;
193
193
  if (!container) return;
194
- if (ctx.columnMenuFor || ctx.operatorMenuFor) ctx.closeMenus();
194
+ if (ctx.columnMenuFor || ctx.operatorMenuFor || ctx.contextMenuFor) ctx.closeMenus();
195
195
  scheduleScrollSync(container.scrollTop, container.scrollLeft);
196
196
  // Mirror horizontal scroll to any aligned grids in the same group.
197
197
  if (ctx.props.alignedGridGroup != null) ctx.broadcastAlignedScroll(container.scrollLeft);
@@ -160,6 +160,7 @@ class SvGridScrollbarElement extends ScrollbarBase {
160
160
  private valueStart = 0
161
161
  private holdTimer: ReturnType<typeof setTimeout> | null = null
162
162
  private resizeObserver: ResizeObserver | null = null
163
+ private resizeFrame = 0
163
164
 
164
165
  connectedCallback() {
165
166
  if (this.shadowRoot) return
@@ -187,7 +188,17 @@ class SvGridScrollbarElement extends ScrollbarBase {
187
188
  btn.addEventListener('pointercancel', this.stopHold)
188
189
  }
189
190
 
190
- this.resizeObserver = new ResizeObserver(() => this.render())
191
+ // Coalesce resize notifications into a single next-frame render so the
192
+ // observer callback never re-lays-out the observed element within the same
193
+ // delivery cycle (which is what triggers the benign but noisy
194
+ // "ResizeObserver loop completed with undelivered notifications" warning).
195
+ this.resizeObserver = new ResizeObserver(() => {
196
+ if (this.resizeFrame) return
197
+ this.resizeFrame = requestAnimationFrame(() => {
198
+ this.resizeFrame = 0
199
+ this.render()
200
+ })
201
+ })
191
202
  this.resizeObserver.observe(this)
192
203
 
193
204
  this.render()
@@ -195,6 +206,8 @@ class SvGridScrollbarElement extends ScrollbarBase {
195
206
 
196
207
  disconnectedCallback() {
197
208
  this.stopHold()
209
+ if (this.resizeFrame) cancelAnimationFrame(this.resizeFrame)
210
+ this.resizeFrame = 0
198
211
  this.resizeObserver?.disconnect()
199
212
  this.resizeObserver = null
200
213
  document.removeEventListener('pointermove', this.onPointerMove)