beautiful-grid 1.0.2 → 1.0.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.
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef } from 'react';
3
3
  import { useShallow } from 'zustand/react/shallow';
4
4
  import { useAppStore } from '../store';
5
5
  import { BGridDataItemStatus, } from '../types';
6
- import { getCellValueByRowKey, getFrozenColumnsWidth, getVisibleScrollableRowRange, isCheckboxValueChecked, markCellEdited, markCellValueChanged, resolveLogicalCell, shouldRenderBottomBar, } from '../utils';
6
+ import { getCellValueByRowKey, getFrozenColumnsWidth, getVisibleScrollableRowRange, getVirtualScrollWindowMetrics, getVirtualScrollWindowPosition, isCheckboxValueChecked, markCellEdited, markCellValueChanged, rebaseVirtualScrollWindow, resolveLogicalCell, shouldRenderBottomBar, } from '../utils';
7
7
  import { clamp, ensureCellVisible } from '../utils/coordinate';
8
8
  import Loading from './Loading';
9
9
  import TableBody from './TableBody';
@@ -183,6 +183,23 @@ function Table(props) {
183
183
  const trHeight = itemHeight + itemPadding * 2;
184
184
  const scrollableBodyHeight = Math.max(contentBodyHeight - frozenRowsHeight, 0);
185
185
  const mainViewportWidth = Math.max(width - (frozenColumnsWidth ?? 0), 0);
186
+ const logicalBodyContentHeight = data.length * trHeight;
187
+ const virtualScrollWindowMetrics = React.useMemo(() => getVirtualScrollWindowMetrics({
188
+ logicalContentHeight: logicalBodyContentHeight,
189
+ viewportHeight: contentBodyHeight,
190
+ enabled: scrollbar.variant !== 'native',
191
+ }), [contentBodyHeight, logicalBodyContentHeight, scrollbar.variant]);
192
+ const [virtualScrollBase, setVirtualScrollBase] = React.useState(0);
193
+ const virtualScrollBaseRef = useRef(0);
194
+ const containerRef = useRef(null);
195
+ const searchPopoverRef = useRef(null);
196
+ const editorPortalRef = useRef(null);
197
+ const bodyContainerRef = useRef(null);
198
+ const scrollContainerRef = useRef(null);
199
+ const logicalVerticalScrollState = React.useMemo(() => ({
200
+ scrollTop,
201
+ scrollHeight: virtualScrollWindowMetrics.logicalContentHeight,
202
+ }), [scrollTop, virtualScrollWindowMetrics.logicalContentHeight]);
186
203
  const hasMultiCellSelection = React.useMemo(() => cellSelectionRanges.length > 1 ||
187
204
  cellSelectionRanges.some(range => {
188
205
  const normalized = normalizeCellSelectionRange(range);
@@ -273,7 +290,7 @@ function Table(props) {
273
290
  top: topQuadrant ? 0 : scrollTop,
274
291
  width: leftQuadrant ? frozenColumnsWidth ?? 0 : mainViewportWidth,
275
292
  height: topQuadrant ? frozenRowsHeight : scrollableBodyHeight,
276
- } }));
293
+ }, offsetTop: !topQuadrant && virtualScrollWindowMetrics.enabled ? virtualScrollBase : 0 }));
277
294
  };
278
295
  const visibleScrollableRows = React.useMemo(() => getVisibleScrollableRowRange({
279
296
  scrollTop,
@@ -288,15 +305,14 @@ function Table(props) {
288
305
  startRowIndex: visibleScrollableRows.startRowIndex,
289
306
  endRowIndex: visibleScrollableRows.endRowIndex,
290
307
  }), [visibleScrollableRows.endRowIndex, visibleScrollableRows.startRowIndex]);
291
- const frozenScrollableBodyStyle = React.useMemo(() => ({ top: visibleScrollableRows.paddingTop }), [visibleScrollableRows.paddingTop]);
292
- const containerRef = useRef(null);
293
- const searchPopoverRef = useRef(null);
308
+ const frozenScrollableBodyStyle = React.useMemo(() => ({
309
+ top: virtualScrollWindowMetrics.enabled
310
+ ? visibleScrollableRows.paddingTop - virtualScrollBase
311
+ : visibleScrollableRows.paddingTop,
312
+ }), [virtualScrollBase, virtualScrollWindowMetrics.enabled, visibleScrollableRows.paddingTop]);
294
313
  const warnedContextMenuIdsRef = useRef(new Set());
295
314
  const warnedContextMenuFactoryRef = useRef(false);
296
- const editorPortalRef = useRef(null);
297
315
  const editorPortalContext = React.useMemo(() => ({ gridRef: containerRef, portalRef: editorPortalRef }), []);
298
- const bodyContainerRef = useRef(null);
299
- const scrollContainerRef = useRef(null);
300
316
  const rowReorderController = useRowReorderController({
301
317
  containerRef,
302
318
  bodyContainerRef,
@@ -404,7 +420,8 @@ function Table(props) {
404
420
  const stickyBottomHeight = summary?.position === 'bottom' ? summaryHeight : 0;
405
421
  const stickyFixedHeight = stickyTopHeight + stickyBottomHeight;
406
422
  const scrollViewportHeight = contentBodyHeight + stickyFixedHeight;
407
- const scrollPlaneHeight = stickyFixedHeight + frozenRowsHeight + visibleScrollableRows.scrollContentHeight;
423
+ const physicalScrollableRowsHeight = Math.max(virtualScrollWindowMetrics.physicalContentHeight - frozenRowsHeight, 0);
424
+ const scrollPlaneHeight = stickyFixedHeight + virtualScrollWindowMetrics.physicalContentHeight;
408
425
  const scrollPlaneContentWidth = (frozenColumnsWidth ?? 0) + scrollableColumnsWidth;
409
426
  const scrollPlaneWidth = Math.max(width, scrollPlaneContentWidth);
410
427
  const customVerticalScrollbarGutter = scrollbar.vertical.visible && scrollbar.variant === 'classic'
@@ -415,7 +432,18 @@ function Table(props) {
415
432
  const scrollPlaneMinWidth = customVerticalScrollbarGutter
416
433
  ? `max(100%, calc(${scrollPlaneContentWidth}px + ${customVerticalScrollbarGutter}))`
417
434
  : scrollPlaneWidth;
418
- const scrollbarMetrics = useScrollbarMetrics(scrollContainerRef, [data.length, itemHeight, itemPadding, contentBodyHeight, width], scrollPlaneContentWidth, stickyFixedHeight);
435
+ const measuredScrollbarMetrics = useScrollbarMetrics(scrollContainerRef, [data.length, itemHeight, itemPadding, contentBodyHeight, width], scrollPlaneContentWidth, stickyFixedHeight);
436
+ const scrollbarMetrics = React.useMemo(() => virtualScrollWindowMetrics.enabled
437
+ ? {
438
+ ...measuredScrollbarMetrics,
439
+ vertical: {
440
+ viewportSize: contentBodyHeight,
441
+ contentSize: virtualScrollWindowMetrics.logicalContentHeight,
442
+ maxScroll: virtualScrollWindowMetrics.logicalMaxScroll,
443
+ hasOverflow: virtualScrollWindowMetrics.logicalMaxScroll > 1,
444
+ },
445
+ }
446
+ : measuredScrollbarMetrics, [contentBodyHeight, measuredScrollbarMetrics, virtualScrollWindowMetrics]);
419
447
  const measuredVerticalScrollbarGutter = customVerticalScrollbarGutter && scrollbarMetrics.horizontal.hasOverflow
420
448
  ? Math.max(scrollbarMetrics.horizontal.contentSize - scrollPlaneContentWidth, 0)
421
449
  : 0;
@@ -435,13 +463,16 @@ function Table(props) {
435
463
  scrollContainerRef.current.scrollLeft = offset;
436
464
  }
437
465
  }, []);
438
- const handleScrollTopChange = useCallback((offset) => {
439
- if (scrollContainerRef.current) {
440
- scrollContainerRef.current.scrollTop = offset;
441
- }
442
- }, []);
443
466
  const latestScrollRef = useRef({ top: scrollTop, left: scrollLeft });
444
467
  const latestCommittedScrollRef = useRef({ top: scrollTop, left: scrollLeft });
468
+ const scrollPropsRef = useRef({ top: props.scrollTop, left: props.scrollLeft });
469
+ const syncedDataPropRef = useRef(props.data);
470
+ const [rowScrollRequest, setRowScrollRequest] = React.useState(null);
471
+ React.useImperativeHandle(props.ref, () => ({
472
+ scrollToRow(rowIndex, options) {
473
+ setRowScrollRequest({ rowIndex, align: options?.align ?? 'nearest' });
474
+ },
475
+ }), []);
445
476
  const scrollRafRef = useRef(null);
446
477
  const scrollIdleTimerRef = useRef(null);
447
478
  const selectionAutoScrollRafRef = useRef(null);
@@ -456,6 +487,7 @@ function Table(props) {
456
487
  frozenRowCount,
457
488
  frozenColumnsWidth: frozenColumnsWidth ?? 0,
458
489
  frozenRowsHeight,
490
+ scrollTop,
459
491
  trHeight,
460
492
  });
461
493
  useEffect(() => {
@@ -466,9 +498,10 @@ function Table(props) {
466
498
  frozenRowCount,
467
499
  frozenColumnsWidth: frozenColumnsWidth ?? 0,
468
500
  frozenRowsHeight,
501
+ scrollTop,
469
502
  trHeight,
470
503
  };
471
- }, [columns, data.length, props.frozenColumnIndex, frozenColumnsWidth, frozenRowCount, frozenRowsHeight, trHeight]);
504
+ }, [columns, data.length, props.frozenColumnIndex, frozenColumnsWidth, frozenRowCount, frozenRowsHeight, scrollTop, trHeight]);
472
505
  const markScrollActive = useCallback(() => {
473
506
  containerRef.current?.setAttribute('data-bgrid-scrolling', 'true');
474
507
  if (scrollIdleTimerRef.current !== null)
@@ -478,6 +511,27 @@ function Table(props) {
478
511
  containerRef.current?.removeAttribute('data-bgrid-scrolling');
479
512
  }, SCROLL_IDLE_DELAY);
480
513
  }, []);
514
+ const commitScroll = useCallback((top, left) => {
515
+ const scrollContainer = scrollContainerRef.current;
516
+ if (!scrollContainer)
517
+ return;
518
+ const position = getVirtualScrollWindowPosition(top, virtualScrollWindowMetrics);
519
+ if (virtualScrollBaseRef.current !== position.base) {
520
+ virtualScrollBaseRef.current = position.base;
521
+ setVirtualScrollBase(position.base);
522
+ }
523
+ if (scrollContainer.scrollTop !== position.physicalScrollTop) {
524
+ scrollContainer.scrollTop = position.physicalScrollTop;
525
+ }
526
+ if (scrollContainer.scrollLeft !== left)
527
+ scrollContainer.scrollLeft = left;
528
+ latestScrollRef.current = { top: position.logicalScrollTop, left };
529
+ latestCommittedScrollRef.current = { top: position.logicalScrollTop, left };
530
+ setScroll(position.logicalScrollTop, left);
531
+ }, [setScroll, virtualScrollWindowMetrics]);
532
+ const handleScrollTopChange = useCallback((offset) => {
533
+ commitScroll(offset, scrollContainerRef.current?.scrollLeft ?? scrollLeft);
534
+ }, [commitScroll, scrollLeft]);
481
535
  const scheduleScrollFrame = useCallback(() => {
482
536
  if (scrollRafRef.current !== null)
483
537
  return;
@@ -486,90 +540,42 @@ function Table(props) {
486
540
  const scrollContainer = scrollContainerRef.current;
487
541
  if (!scrollContainer)
488
542
  return;
489
- const { scrollTop, scrollLeft } = scrollContainer;
490
- latestScrollRef.current = { top: scrollTop, left: scrollLeft };
543
+ const { scrollTop: physicalScrollTop, scrollLeft } = scrollContainer;
544
+ const position = rebaseVirtualScrollWindow(virtualScrollBaseRef.current, physicalScrollTop, virtualScrollWindowMetrics);
545
+ if (virtualScrollBaseRef.current !== position.base) {
546
+ virtualScrollBaseRef.current = position.base;
547
+ setVirtualScrollBase(position.base);
548
+ }
549
+ if (physicalScrollTop !== position.physicalScrollTop) {
550
+ scrollContainer.scrollTop = position.physicalScrollTop;
551
+ }
552
+ const logicalScrollTop = position.logicalScrollTop;
553
+ latestScrollRef.current = { top: logicalScrollTop, left: scrollLeft };
491
554
  const { top: committedTop, left: committedLeft } = latestCommittedScrollRef.current;
492
- if (committedTop !== scrollTop || committedLeft !== scrollLeft) {
493
- latestCommittedScrollRef.current = { top: scrollTop, left: scrollLeft };
494
- setScroll(scrollTop, scrollLeft);
555
+ if (committedTop !== logicalScrollTop || committedLeft !== scrollLeft) {
556
+ latestCommittedScrollRef.current = { top: logicalScrollTop, left: scrollLeft };
557
+ setScroll(logicalScrollTop, scrollLeft);
495
558
  }
496
559
  if (containerRef.current?.hasAttribute('data-bgrid-scrolling')) {
497
560
  scrollRafRef.current = requestAnimationFrame(pollNativeScrollPosition);
498
561
  }
499
562
  };
500
563
  scrollRafRef.current = requestAnimationFrame(pollNativeScrollPosition);
501
- }, [setScroll]);
564
+ }, [setScroll, virtualScrollWindowMetrics]);
502
565
  const onScroll = useCallback(() => {
503
566
  if (scrollContainerRef.current) {
504
- const { scrollTop, scrollLeft } = scrollContainerRef.current;
505
- latestScrollRef.current = { top: scrollTop, left: scrollLeft };
506
567
  markScrollActive();
507
568
  scheduleScrollFrame();
508
569
  }
509
570
  }, [markScrollActive, scheduleScrollFrame]);
510
- const onWheel = useCallback(evt => {
511
- const scrollContainer = scrollContainerRef.current;
512
- if (!scrollContainer)
513
- return;
514
- const delta = { x: 0, y: 0 };
515
- if (evt.detail) {
516
- delta.y = evt.detail * 10;
517
- }
518
- else if (typeof evt.deltaY === 'undefined') {
519
- delta.y = -evt.wheelDelta;
520
- delta.x = 0;
521
- }
522
- else {
523
- delta.y = evt.deltaY;
524
- delta.x = evt.deltaX;
525
- }
526
- if (evt.deltaMode === 1) {
527
- delta.y = delta.y * 16;
528
- delta.x = delta.x * 16;
529
- }
530
- else if (evt.deltaMode === 2) {
531
- delta.y = delta.y * scrollContainer.clientHeight;
532
- delta.x = delta.x * scrollContainer.clientWidth;
533
- }
534
- const maxScrollTop = Math.max(scrollContainer.scrollHeight - scrollContainer.clientHeight, 0);
535
- const maxScrollLeft = Math.max(scrollContainer.scrollWidth - scrollContainer.clientWidth, 0);
536
- const nextScrollTop = Math.min(maxScrollTop, Math.max(0, scrollContainer.scrollTop + delta.y));
537
- const nextScrollLeft = Math.min(maxScrollLeft, Math.max(0, scrollContainer.scrollLeft + delta.x));
538
- const didScrollTop = nextScrollTop !== scrollContainer.scrollTop;
539
- const didScrollLeft = nextScrollLeft !== scrollContainer.scrollLeft;
540
- const isVertical = Math.abs(delta.y) > Math.abs(delta.x);
541
- if (isVertical) {
542
- if (!didScrollTop)
543
- return;
544
- }
545
- else {
546
- if (!didScrollLeft)
547
- return;
548
- }
549
- evt.preventDefault();
550
- if (didScrollTop)
551
- scrollContainer.scrollTop = nextScrollTop;
552
- if (didScrollLeft)
553
- scrollContainer.scrollLeft = nextScrollLeft;
554
- }, []);
555
571
  const resetScrollPosition = useCallback((type) => {
556
- if (type === 'all' || type === 'left')
557
- scrollContainerRef.current?.scrollTo({ left: 0 });
558
- if (type === 'all' || type === 'top')
559
- scrollContainerRef.current?.scrollTo({ top: 0 });
560
- }, []);
561
- const commitScroll = useCallback((top, left) => {
562
572
  const scrollContainer = scrollContainerRef.current;
563
573
  if (!scrollContainer)
564
574
  return;
565
- if (scrollContainer.scrollTop !== top)
566
- scrollContainer.scrollTop = top;
567
- if (scrollContainer.scrollLeft !== left)
568
- scrollContainer.scrollLeft = left;
569
- latestScrollRef.current = { top, left };
570
- latestCommittedScrollRef.current = { top, left };
571
- setScroll(top, left);
572
- }, [setScroll]);
575
+ const nextTop = type === 'all' || type === 'top' ? 0 : latestScrollRef.current.top;
576
+ const nextLeft = type === 'all' || type === 'left' ? 0 : scrollContainer.scrollLeft;
577
+ commitScroll(nextTop, nextLeft);
578
+ }, [commitScroll]);
573
579
  useEffect(() => {
574
580
  if (!searchOpen || activeSearchMatchIndex === undefined)
575
581
  return;
@@ -593,6 +599,7 @@ function Table(props) {
593
599
  frozenRowCount,
594
600
  columns,
595
601
  rowHeight: trHeight,
602
+ verticalScrollState: logicalVerticalScrollState,
596
603
  viewportInsets,
597
604
  });
598
605
  if (result.didScroll)
@@ -602,6 +609,7 @@ function Table(props) {
602
609
  columns,
603
610
  commitScroll,
604
611
  frozenRowCount,
612
+ logicalVerticalScrollState,
605
613
  props.frozenColumnIndex,
606
614
  searchMatches,
607
615
  searchOpen,
@@ -833,18 +841,18 @@ function Table(props) {
833
841
  if (!dragState || !pointer || !scrollContainer || !bodyContainer)
834
842
  return;
835
843
  const delta = getSelectionAutoScrollDelta(pointer.clientX, pointer.clientY, bodyContainer, scrollContainer);
836
- const maxScrollTop = Math.max(scrollContainer.scrollHeight - scrollContainer.clientHeight, 0);
844
+ const maxScrollTop = virtualScrollWindowMetrics.logicalMaxScroll;
837
845
  const maxScrollLeft = Math.max(scrollContainer.scrollWidth - scrollContainer.clientWidth, 0);
838
- const nextScrollTop = clamp(scrollContainer.scrollTop + delta.y, 0, maxScrollTop);
846
+ const nextScrollTop = clamp(latestScrollRef.current.top + delta.y, 0, maxScrollTop);
839
847
  const nextScrollLeft = clamp(scrollContainer.scrollLeft + delta.x, 0, maxScrollLeft);
840
- if (nextScrollTop !== scrollContainer.scrollTop || nextScrollLeft !== scrollContainer.scrollLeft) {
848
+ if (nextScrollTop !== latestScrollRef.current.top || nextScrollLeft !== scrollContainer.scrollLeft) {
841
849
  commitScroll(nextScrollTop, nextScrollLeft);
842
850
  }
843
851
  updateSelectionByPointer(pointer.clientX, pointer.clientY);
844
852
  if (cellSelectionDragRef.current && (delta.x !== 0 || delta.y !== 0)) {
845
853
  selectionAutoScrollRafRef.current = requestAnimationFrame(runSelectionAutoScroll);
846
854
  }
847
- }, [commitScroll, updateSelectionByPointer]);
855
+ }, [commitScroll, updateSelectionByPointer, virtualScrollWindowMetrics.logicalMaxScroll]);
848
856
  const selectAllCells = useCallback(() => {
849
857
  if (!cellSelectionEnabled)
850
858
  return false;
@@ -1333,6 +1341,7 @@ function Table(props) {
1333
1341
  setSourceData(props.sourceData);
1334
1342
  }
1335
1343
  }
1344
+ syncedDataPropRef.current = props.data;
1336
1345
  }, [
1337
1346
  props.data,
1338
1347
  props.sourceData,
@@ -1411,8 +1420,11 @@ function Table(props) {
1411
1420
  setPage,
1412
1421
  setDisplayPaginationLength,
1413
1422
  ]);
1423
+ const previousPageRef = useRef(props.page?.currentPage);
1414
1424
  useEffect(() => {
1415
- resetScrollPosition('top');
1425
+ if (previousPageRef.current !== props.page?.currentPage)
1426
+ resetScrollPosition('top');
1427
+ previousPageRef.current = props.page?.currentPage;
1416
1428
  }, [resetScrollPosition, props.page?.currentPage]);
1417
1429
  // [Group 8] Row key & selection
1418
1430
  useEffect(() => {
@@ -1497,10 +1509,8 @@ function Table(props) {
1497
1509
  const containerRefCurrent = containerRef.current;
1498
1510
  const scrollContainerRefCurrent = scrollContainerRef?.current;
1499
1511
  if (scrollContainerRefCurrent) {
1500
- scrollContainerRefCurrent.removeEventListener('scroll', onScroll);
1512
+ scrollContainerRefCurrent.removeEventListener('scroll', onScroll, true);
1501
1513
  scrollContainerRefCurrent.addEventListener('scroll', onScroll, { passive: true, capture: true });
1502
- // scrollContainerRefCurrent.removeEventListener('wheel', onWheel);
1503
- // scrollContainerRefCurrent.addEventListener('wheel', onWheel, { passive: false, capture: true });
1504
1514
  }
1505
1515
  return () => {
1506
1516
  if (scrollRafRef.current !== null) {
@@ -1512,20 +1522,56 @@ function Table(props) {
1512
1522
  scrollIdleTimerRef.current = null;
1513
1523
  }
1514
1524
  containerRefCurrent?.removeAttribute('data-bgrid-scrolling');
1515
- scrollContainerRefCurrent?.removeEventListener('scroll', onScroll);
1525
+ scrollContainerRefCurrent?.removeEventListener('scroll', onScroll, true);
1516
1526
  };
1517
- }, [onScroll, onWheel, setInitialized]);
1527
+ }, [onScroll, setInitialized]);
1518
1528
  React.useLayoutEffect(() => {
1519
1529
  if (!scrollContainerRef.current)
1520
1530
  return;
1521
- if (props.scrollLeft !== undefined) {
1522
- scrollContainerRef.current.scrollLeft = props.scrollLeft;
1523
- }
1524
- if (props.scrollTop !== undefined) {
1525
- scrollContainerRef.current.scrollTop = props.scrollTop;
1526
- }
1527
- }, [props.scrollLeft, props.scrollTop]);
1531
+ if (props.data !== syncedDataPropRef.current)
1532
+ return;
1533
+ // Resizing or changing the data also changes commitScroll. Only a changed
1534
+ // scroll prop should override the user's current position.
1535
+ const nextTop = props.scrollTop !== scrollPropsRef.current.top
1536
+ ? props.scrollTop ?? latestScrollRef.current.top
1537
+ : latestScrollRef.current.top;
1538
+ const nextLeft = props.scrollLeft !== scrollPropsRef.current.left
1539
+ ? props.scrollLeft ?? latestScrollRef.current.left
1540
+ : latestScrollRef.current.left;
1541
+ scrollPropsRef.current = { top: props.scrollTop, left: props.scrollLeft };
1542
+ commitScroll(nextTop, nextLeft);
1543
+ }, [commitScroll, data, props.data, props.scrollLeft, props.scrollTop]);
1544
+ React.useLayoutEffect(() => {
1545
+ if (!rowScrollRequest)
1546
+ return;
1547
+ // Prop data reaches the per-grid store in an effect. Wait for that render
1548
+ // so appending a row and requesting it in the same event is supported.
1549
+ if (props.data !== syncedDataPropRef.current)
1550
+ return;
1551
+ setRowScrollRequest(null);
1552
+ const { rowIndex, align } = rowScrollRequest;
1553
+ if (!Number.isInteger(rowIndex) || rowIndex < frozenRowCount || rowIndex >= data.length)
1554
+ return;
1555
+ if (scrollableBodyHeight <= 0)
1556
+ return;
1557
+ const rowTop = (rowIndex - frozenRowCount) * trHeight;
1558
+ const rowBottom = rowTop + trHeight;
1559
+ const { top, left } = latestScrollRef.current;
1560
+ let nextTop = top;
1561
+ if (align === 'start')
1562
+ nextTop = rowTop;
1563
+ else if (align === 'center')
1564
+ nextTop = rowTop - (scrollableBodyHeight - trHeight) / 2;
1565
+ else if (align === 'end')
1566
+ nextTop = rowBottom - scrollableBodyHeight;
1567
+ else if (rowTop < top)
1568
+ nextTop = rowTop;
1569
+ else if (rowBottom > top + scrollableBodyHeight)
1570
+ nextTop = rowBottom - scrollableBodyHeight;
1571
+ commitScroll(nextTop, left);
1572
+ }, [commitScroll, data, frozenRowCount, props.data, rowScrollRequest, scrollableBodyHeight, trHeight]);
1528
1573
  useEffect(() => {
1574
+ latestScrollRef.current = { top: scrollTop, left: scrollLeft };
1529
1575
  latestCommittedScrollRef.current = { top: scrollTop, left: scrollLeft };
1530
1576
  }, [scrollTop, scrollLeft]);
1531
1577
  useEffect(() => {
@@ -1606,6 +1652,7 @@ function Table(props) {
1606
1652
  searchOpen,
1607
1653
  searchOptions,
1608
1654
  trHeight,
1655
+ verticalScrollState: logicalVerticalScrollState,
1609
1656
  viewportInsets: keyboardViewportInsets,
1610
1657
  });
1611
1658
  keyboardRuntimeRef.current = {
@@ -1638,6 +1685,7 @@ function Table(props) {
1638
1685
  searchOpen,
1639
1686
  searchOptions,
1640
1687
  trHeight,
1688
+ verticalScrollState: logicalVerticalScrollState,
1641
1689
  viewportInsets: keyboardViewportInsets,
1642
1690
  };
1643
1691
  useEffect(() => {
@@ -1653,7 +1701,7 @@ function Table(props) {
1653
1701
  }
1654
1702
  };
1655
1703
  const moveWithArrowKey = (key, extendSelection, toBoundary = false) => {
1656
- const { activeCell, cellInteractionSession, cellNavigationOptions, cellSelectionEnabled, columns, commitScroll, dataLength, frozenColumnIndex, frozenRowCount, moveActiveCell, trHeight, viewportInsets, } = keyboardRuntimeRef.current;
1704
+ const { activeCell, cellInteractionSession, cellNavigationOptions, cellSelectionEnabled, columns, commitScroll, dataLength, frozenColumnIndex, frozenRowCount, moveActiveCell, trHeight, verticalScrollState, viewportInsets, } = keyboardRuntimeRef.current;
1657
1705
  const container = containerRef.current;
1658
1706
  const activeElement = document.activeElement;
1659
1707
  if (!container ||
@@ -1687,6 +1735,7 @@ function Table(props) {
1687
1735
  frozenRowCount,
1688
1736
  columns,
1689
1737
  rowHeight: trHeight,
1738
+ verticalScrollState,
1690
1739
  viewportInsets,
1691
1740
  });
1692
1741
  if (result.didScroll) {
@@ -1718,7 +1767,7 @@ function Table(props) {
1718
1767
  arrowRepeatFrame = requestAnimationFrame(runArrowRepeatFrame);
1719
1768
  };
1720
1769
  const handleKeyDown = (evt) => {
1721
- const { activeCell, cellInteractionSession, cellNavigationOptions, cellSelectionEnabled, clearCellSelection, clearCellSelectionOnEscape, columns, commitCheckboxCell, commitScroll, copySelectedCells, data, dataLength, editable, editItemIndex, endCellSelectionDrag, frozenColumnIndex, frozenRowCount, height, handleClick, moveActiveCell, openCellContextMenu, selectAllCells, setEditItem, requestSearchOpen, searchOpen, searchOptions, trHeight, viewportInsets, } = keyboardRuntimeRef.current;
1770
+ const { activeCell, cellInteractionSession, cellNavigationOptions, cellSelectionEnabled, clearCellSelection, clearCellSelectionOnEscape, columns, commitCheckboxCell, commitScroll, copySelectedCells, data, dataLength, editable, editItemIndex, endCellSelectionDrag, frozenColumnIndex, frozenRowCount, height, handleClick, moveActiveCell, openCellContextMenu, selectAllCells, setEditItem, requestSearchOpen, searchOpen, searchOptions, trHeight, verticalScrollState, viewportInsets, } = keyboardRuntimeRef.current;
1722
1771
  const container = containerRef.current;
1723
1772
  const activeElement = document.activeElement;
1724
1773
  if (!container || (activeElement !== container && !container.contains(activeElement)))
@@ -1846,6 +1895,7 @@ function Table(props) {
1846
1895
  frozenRowCount,
1847
1896
  columns,
1848
1897
  rowHeight: trHeight,
1898
+ verticalScrollState,
1849
1899
  viewportInsets,
1850
1900
  });
1851
1901
  if (result.didScroll) {
@@ -1889,6 +1939,7 @@ function Table(props) {
1889
1939
  frozenRowCount,
1890
1940
  columns,
1891
1941
  rowHeight: trHeight,
1942
+ verticalScrollState,
1892
1943
  viewportInsets,
1893
1944
  });
1894
1945
  if (result.didScroll) {
@@ -1916,6 +1967,7 @@ function Table(props) {
1916
1967
  frozenRowCount,
1917
1968
  columns,
1918
1969
  rowHeight: trHeight,
1970
+ verticalScrollState,
1919
1971
  viewportInsets,
1920
1972
  });
1921
1973
  if (result.didScroll) {
@@ -2022,7 +2074,7 @@ function Table(props) {
2022
2074
  event.stopPropagation();
2023
2075
  }
2024
2076
  } },
2025
- React.createElement(ScrollPlane, { style: { height: scrollPlaneHeight, minWidth: scrollPlaneMinWidth } },
2077
+ React.createElement(ScrollPlane, { style: { height: scrollPlaneHeight, minWidth: scrollPlaneMinWidth }, "data-bgrid-virtual-scroll-window": virtualScrollWindowMetrics.enabled ? 'true' : undefined, "data-bgrid-logical-height": virtualScrollWindowMetrics.logicalContentHeight, "data-bgrid-physical-height": virtualScrollWindowMetrics.physicalContentHeight, "data-bgrid-logical-scroll-top": Math.round(scrollTop), "data-bgrid-virtual-scroll-base": virtualScrollWindowMetrics.enabled ? Math.round(virtualScrollBase) : undefined },
2026
2078
  React.createElement(HeaderContainer, { style: { height: headerHeight, top: 0 }, role: 'rfdg-header-container' },
2027
2079
  (frozenColumnsWidth ?? 0) > 0 && (React.createElement(FrozenHeader, { style: { width: frozenColumnsWidth }, role: 'rfdg-frozen-header' },
2028
2080
  React.createElement(TableHeadFrozen, { container: containerRef }))),
@@ -2033,7 +2085,7 @@ function Table(props) {
2033
2085
  React.createElement(TableSummaryFrozen, { position: 'top' }))),
2034
2086
  React.createElement(Summary, { style: { left: frozenColumnsWidth }, role: 'rfdg-summary' },
2035
2087
  React.createElement(TableSummary, { position: 'top' })))),
2036
- React.createElement("div", { className: 'bgrid-body-scroll-content', style: { height: frozenRowsHeight + visibleScrollableRows.scrollContentHeight } },
2088
+ React.createElement("div", { className: 'bgrid-body-scroll-content', style: { height: virtualScrollWindowMetrics.physicalContentHeight } },
2037
2089
  frozenRowCount > 0 && (React.createElement("div", { className: 'bgrid-frozen-rows-layer', style: { height: frozenRowsHeight, top: stickyTopHeight }, "data-bgrid-row-band": 'frozen' },
2038
2090
  (frozenColumnsWidth ?? 0) > 0 && (React.createElement("div", { className: 'bgrid-frozen-rows-left bgrid-frozen-column-boundary', style: { width: frozenColumnsWidth }, role: 'rfdg-frozen-rows-left' },
2039
2091
  React.createElement(TableBodyFrozen, { scrollContainerRef: scrollContainerRef, rowRange: frozenRowRange, role: 'rfdg-body-top-frozen', quadrant: 'top-left', allowRowReorder: false }),
@@ -2041,17 +2093,19 @@ function Table(props) {
2041
2093
  React.createElement("div", { className: 'bgrid-frozen-rows-main', style: { left: frozenColumnsWidth }, role: 'rfdg-frozen-rows-main' },
2042
2094
  React.createElement(TableBody, { scrollContainerRef: scrollContainerRef, rowRange: frozenRowRange, role: 'rfdg-body-top', quadrant: 'top-main', allowRowReorder: false }),
2043
2095
  renderSelectionOverlay('top-main')))),
2044
- React.createElement("div", { className: 'bgrid-scrollable-rows-layer', style: { height: visibleScrollableRows.scrollContentHeight }, "data-bgrid-row-band": 'scrollable' },
2096
+ React.createElement("div", { className: 'bgrid-scrollable-rows-layer', style: { height: physicalScrollableRowsHeight }, "data-bgrid-row-band": 'scrollable' },
2045
2097
  (frozenColumnsWidth ?? 0) > 0 && (React.createElement(FrozenScrollContent, { style: {
2046
2098
  width: frozenColumnsWidth,
2047
- height: visibleScrollableRows.scrollContentHeight,
2099
+ height: physicalScrollableRowsHeight,
2048
2100
  }, role: 'rfdg-frozen-scroll-container' },
2049
2101
  React.createElement(TableBodyFrozen, { scrollContainerRef: scrollContainerRef, rowRange: scrollableRowRange, style: frozenScrollableBodyStyle, quadrant: 'body-left', onRowReorderPointerDown: rowReorderController.onPointerDown, onRowReorderKeyDown: rowReorderController.onKeyDown }),
2050
2102
  renderSelectionOverlay('body-left'))),
2051
2103
  React.createElement(ScrollContent, { style: {
2052
2104
  left: frozenColumnsWidth,
2053
- paddingTop: visibleScrollableRows.paddingTop,
2054
- height: visibleScrollableRows.scrollContentHeight,
2105
+ paddingTop: virtualScrollWindowMetrics.enabled
2106
+ ? visibleScrollableRows.paddingTop - virtualScrollBase
2107
+ : visibleScrollableRows.paddingTop,
2108
+ height: physicalScrollableRowsHeight,
2055
2109
  } },
2056
2110
  React.createElement(TableBody, { scrollContainerRef: scrollContainerRef, rowRange: scrollableRowRange, quadrant: 'body-main' }),
2057
2111
  renderSelectionOverlay('body-main')))),
@@ -2141,7 +2195,7 @@ function getCellPositionFromPointer({ clientX, clientY, bodyContainer, scrollCon
2141
2195
  const rowIndex = metrics.frozenRowCount > 0 && clientY < frozenRowsBottom
2142
2196
  ? clamp(Math.floor((clientY - bodyRect.top) / metrics.trHeight), 0, metrics.frozenRowCount - 1)
2143
2197
  : clamp(metrics.frozenRowCount +
2144
- Math.floor((scrollContainer.scrollTop +
2198
+ Math.floor((metrics.scrollTop +
2145
2199
  clamp(clientY - scrollRect.top, 0, Math.max(scrollContainer.clientHeight - 1, 0))) /
2146
2200
  metrics.trHeight), 0, metrics.dataLength - 1);
2147
2201
  const columnIndex = getColumnIndexFromPointer({
@@ -1,14 +1,14 @@
1
1
  import * as React from 'react';
2
2
  import { clipCellSelectionFragment, } from '../../utils/cellSelectionGeometry';
3
- export function CellSelectionOverlayLayer({ quadrant, selectionFragments, activeFragments, activeFill, activeRing, viewport, }) {
3
+ export function CellSelectionOverlayLayer({ quadrant, selectionFragments, activeFragments, activeFill, activeRing, viewport, offsetTop = 0, }) {
4
4
  const quadrantSelectionFragments = getVisibleFragments(selectionFragments, quadrant, viewport);
5
5
  const quadrantActiveFragments = getVisibleFragments(activeFragments, quadrant, viewport);
6
6
  if (quadrantSelectionFragments.length === 0 && quadrantActiveFragments.length === 0)
7
7
  return null;
8
8
  return (React.createElement("div", { className: 'bgrid-cell-selection-overlay-layer', "data-bgrid-selection-quadrant": quadrant, "aria-hidden": 'true' },
9
- quadrantSelectionFragments.map(fragment => (React.createElement("div", { key: `selection-${fragment.rangeIndex}`, className: 'bgrid-cell-selection-fragment', "data-bgrid-selection-fragment": 'true', "data-bgrid-selection-range-index": fragment.rangeIndex, "data-edge-top": fragment.edges.top ? 'true' : undefined, "data-edge-right": fragment.edges.right ? 'true' : undefined, "data-edge-bottom": fragment.edges.bottom ? 'true' : undefined, "data-edge-left": fragment.edges.left ? 'true' : undefined, style: getFragmentStyle(fragment) }))),
9
+ quadrantSelectionFragments.map(fragment => (React.createElement("div", { key: `selection-${fragment.rangeIndex}`, className: 'bgrid-cell-selection-fragment', "data-bgrid-selection-fragment": 'true', "data-bgrid-selection-range-index": fragment.rangeIndex, "data-edge-top": fragment.edges.top ? 'true' : undefined, "data-edge-right": fragment.edges.right ? 'true' : undefined, "data-edge-bottom": fragment.edges.bottom ? 'true' : undefined, "data-edge-left": fragment.edges.left ? 'true' : undefined, style: getFragmentStyle(fragment, offsetTop) }))),
10
10
  (activeFill || activeRing) &&
11
- quadrantActiveFragments.map(fragment => (React.createElement("div", { key: `active-${fragment.rangeIndex}`, className: 'bgrid-cell-active-fragment', "data-bgrid-active-fragment": 'true', "data-active-fill": activeFill ? 'true' : undefined, "data-active-ring": activeRing ? 'true' : undefined, style: getFragmentStyle(fragment) })))));
11
+ quadrantActiveFragments.map(fragment => (React.createElement("div", { key: `active-${fragment.rangeIndex}`, className: 'bgrid-cell-active-fragment', "data-bgrid-active-fragment": 'true', "data-active-fill": activeFill ? 'true' : undefined, "data-active-ring": activeRing ? 'true' : undefined, style: getFragmentStyle(fragment, offsetTop) })))));
12
12
  }
13
13
  function getVisibleFragments(fragments, quadrant, viewport) {
14
14
  return fragments.flatMap(fragment => {
@@ -18,10 +18,10 @@ function getVisibleFragments(fragments, quadrant, viewport) {
18
18
  return visibleFragment ? [visibleFragment] : [];
19
19
  });
20
20
  }
21
- function getFragmentStyle(fragment) {
21
+ function getFragmentStyle(fragment, offsetTop) {
22
22
  return {
23
23
  left: fragment.left,
24
- top: fragment.top,
24
+ top: fragment.top - offsetTop,
25
25
  width: fragment.width,
26
26
  height: fragment.height,
27
27
  };
@@ -41,8 +41,8 @@ export function getRowBottom(rowIndex, rowHeight) {
41
41
  return 0;
42
42
  return (rowIndex + 1) * rowHeight;
43
43
  }
44
- export function ensureCellVisible({ cell, scrollContainer, frozenColumnCount = 0, frozenRowCount = 0, columns, rowHeight, viewportInsets, }) {
45
- const currentScrollTop = scrollContainer?.scrollTop ?? 0;
44
+ export function ensureCellVisible({ cell, scrollContainer, frozenColumnCount = 0, frozenRowCount = 0, columns, rowHeight, verticalScrollState, viewportInsets, }) {
45
+ const currentScrollTop = verticalScrollState?.scrollTop ?? scrollContainer?.scrollTop ?? 0;
46
46
  const currentScrollLeft = scrollContainer?.scrollLeft ?? 0;
47
47
  let nextScrollTop = currentScrollTop;
48
48
  let nextScrollLeft = currentScrollLeft;
@@ -52,7 +52,7 @@ export function ensureCellVisible({ cell, scrollContainer, frozenColumnCount = 0
52
52
  const rowTop = getRowTop(targetRowIndex, rowHeight);
53
53
  const rowBottom = getRowBottom(targetRowIndex, rowHeight);
54
54
  const clientHeight = scrollContainer.clientHeight;
55
- const scrollHeight = scrollContainer.scrollHeight;
55
+ const scrollHeight = verticalScrollState?.scrollHeight ?? scrollContainer.scrollHeight;
56
56
  const maxScrollTop = Math.max(0, scrollHeight - clientHeight);
57
57
  if (clientHeight > 0) {
58
58
  const topInset = clamp(viewportInsets?.top ?? 0, 0, clientHeight);
@@ -95,7 +95,7 @@ export function ensureCellVisible({ cell, scrollContainer, frozenColumnCount = 0
95
95
  const didScrollLeft = nextScrollLeft !== currentScrollLeft;
96
96
  const didScroll = didScrollTop || didScrollLeft;
97
97
  if (scrollContainer) {
98
- if (didScrollTop) {
98
+ if (didScrollTop && !verticalScrollState) {
99
99
  scrollContainer.scrollTop = nextScrollTop;
100
100
  }
101
101
  if (didScrollLeft) {
@@ -15,6 +15,7 @@ export * from './scrollbar';
15
15
  export * from './number';
16
16
  export * from './buildHeaderMatrix';
17
17
  export * from './getVisibleScrollableRowRange';
18
+ export * from './virtualScrollWindow';
18
19
  export * from './cellEditState';
19
20
  export * from './getCellSelectionAxisState';
20
21
  export * from './mergedCells';
@@ -0,0 +1,59 @@
1
+ export const BGRID_MAX_PHYSICAL_SCROLL_HEIGHT = 1000000;
2
+ export function getVirtualScrollWindowMetrics({ logicalContentHeight, viewportHeight, enabled, maxPhysicalHeight = BGRID_MAX_PHYSICAL_SCROLL_HEIGHT, }) {
3
+ const safeViewportHeight = Math.max(viewportHeight, 0);
4
+ const safeLogicalContentHeight = Math.max(logicalContentHeight, safeViewportHeight);
5
+ const safeMaxPhysicalHeight = Math.max(maxPhysicalHeight, safeViewportHeight);
6
+ const windowingEnabled = enabled && safeLogicalContentHeight > safeMaxPhysicalHeight;
7
+ const physicalContentHeight = windowingEnabled ? safeMaxPhysicalHeight : safeLogicalContentHeight;
8
+ return {
9
+ enabled: windowingEnabled,
10
+ logicalContentHeight: safeLogicalContentHeight,
11
+ physicalContentHeight,
12
+ logicalMaxScroll: Math.max(safeLogicalContentHeight - safeViewportHeight, 0),
13
+ physicalMaxScroll: Math.max(physicalContentHeight - safeViewportHeight, 0),
14
+ };
15
+ }
16
+ export function getLogicalScrollTop(base, physicalScrollTop, metrics) {
17
+ return clamp(base + physicalScrollTop, 0, metrics.logicalMaxScroll);
18
+ }
19
+ export function getVirtualScrollWindowPosition(logicalScrollTop, metrics) {
20
+ const logicalTop = clamp(logicalScrollTop, 0, metrics.logicalMaxScroll);
21
+ if (!metrics.enabled) {
22
+ return {
23
+ base: 0,
24
+ physicalScrollTop: logicalTop,
25
+ logicalScrollTop: logicalTop,
26
+ };
27
+ }
28
+ const preferredPhysicalTop = metrics.physicalMaxScroll / 2;
29
+ const maxBase = Math.max(metrics.logicalMaxScroll - metrics.physicalMaxScroll, 0);
30
+ const base = clamp(logicalTop - preferredPhysicalTop, 0, maxBase);
31
+ return {
32
+ base,
33
+ physicalScrollTop: logicalTop - base,
34
+ logicalScrollTop: logicalTop,
35
+ };
36
+ }
37
+ export function rebaseVirtualScrollWindow(base, physicalScrollTop, metrics) {
38
+ const logicalScrollTop = getLogicalScrollTop(base, physicalScrollTop, metrics);
39
+ if (!metrics.enabled || metrics.physicalMaxScroll <= 0) {
40
+ return getVirtualScrollWindowPosition(logicalScrollTop, metrics);
41
+ }
42
+ const lowerGuard = metrics.physicalMaxScroll * 0.25;
43
+ const upperGuard = metrics.physicalMaxScroll * 0.75;
44
+ const maxBase = Math.max(metrics.logicalMaxScroll - metrics.physicalMaxScroll, 0);
45
+ const canMoveBackward = base > 0;
46
+ const canMoveForward = base < maxBase;
47
+ if ((physicalScrollTop < lowerGuard && canMoveBackward) ||
48
+ (physicalScrollTop > upperGuard && canMoveForward)) {
49
+ return getVirtualScrollWindowPosition(logicalScrollTop, metrics);
50
+ }
51
+ return {
52
+ base,
53
+ physicalScrollTop: clamp(physicalScrollTop, 0, metrics.physicalMaxScroll),
54
+ logicalScrollTop,
55
+ };
56
+ }
57
+ function clamp(value, min, max) {
58
+ return Math.min(Math.max(value, min), max);
59
+ }