diff --git a/src/components/Table/GridTable.tsx b/src/components/Table/GridTable.tsx index ad8acbe9d..fe013e7ce 100644 --- a/src/components/Table/GridTable.tsx +++ b/src/components/Table/GridTable.tsx @@ -1,7 +1,7 @@ import memoizeOne from "memoize-one"; import { runInAction } from "mobx"; import React, { MutableRefObject, ReactElement, useEffect, useMemo, useRef, useState } from "react"; -import { Components, Virtuoso, VirtuosoHandle } from "react-virtuoso"; +import { Components, ListRange, Virtuoso, VirtuosoHandle } from "react-virtuoso"; import { getTableRefWidthStyles, Loader } from "src/components"; import { DiscriminateUnion, GridRowKind } from "src/components/index"; import { PresentationFieldProps, PresentationProvider } from "src/components/PresentationContext"; @@ -231,6 +231,8 @@ export function GridTable = an // We only use this in as=virtual mode, but keep this here for rowLookup to use const virtuosoRef = useRef(null); + // Stores the current rendered range of rows from virtuoso (used for determining if we can skip re-scrolling to a row if already in view) + const virtuosoRangeRef = useRef(null); // Use this ref to watch for changes in the GridTable's container and resize columns accordingly. const resizeRef = useRef(null); @@ -238,7 +240,7 @@ export function GridTable = an () => { // Let the user pass in their own api handle, otherwise make our own const api = (props.api as GridTableApiImpl) ?? new GridTableApiImpl(); - api.init(persistCollapse, virtuosoRef); + api.init(persistCollapse, virtuosoRef, virtuosoRangeRef); api.setActiveRowId(activeRowId); api.setActiveCellId(activeCellId); // Push the initial columns directly into tableState, b/c that is what @@ -505,6 +507,7 @@ export function GridTable = an stickyHeader, xss, virtuosoRef, + virtuosoRangeRef, tableHeadRows, stickyOffset, infiniteScroll, @@ -532,6 +535,7 @@ function renderDiv( stickyHeader: boolean, xss: any, _virtuosoRef: MutableRefObject, + _virtuosoRangeRef: MutableRefObject, tableHeadRows: ReactElement[], stickyOffset: number, _infiniteScroll?: InfiniteScroll, @@ -592,6 +596,7 @@ function renderTable( stickyHeader: boolean, xss: any, _virtuosoRef: MutableRefObject, + _virtuosoRangeRef: MutableRefObject, tableHeadRows: ReactElement[], stickyOffset: number, _infiniteScroll?: InfiniteScroll, @@ -659,6 +664,7 @@ function renderVirtual( stickyHeader: boolean, xss: any, virtuosoRef: MutableRefObject, + virtuosoRangeRef: MutableRefObject, tableHeadRows: ReactElement[], _stickyOffset: number, infiniteScroll?: InfiniteScroll, @@ -737,6 +743,9 @@ function renderVirtual( // Lastly render the table body rows return visibleDataRows[index]; }} + rangeChanged={(newRange) => { + virtuosoRangeRef.current = newRange; + }} totalCount={tableHeadRows.length + (firstRowMessage ? 1 : 0) + visibleDataRows.length + keptSelectedRows.length} // When implementing infinite scroll, default the bottom `increaseViewportBy` to 500px. This creates the "infinite" // effect such that the next page of data is (hopefully) loaded before the user reaches the true bottom diff --git a/src/components/Table/GridTableApi.ts b/src/components/Table/GridTableApi.ts index 0cda89472..f4aa6c377 100644 --- a/src/components/Table/GridTableApi.ts +++ b/src/components/Table/GridTableApi.ts @@ -1,7 +1,7 @@ import { comparer } from "mobx"; import { computedFn } from "mobx-utils"; import { MutableRefObject, useMemo } from "react"; -import { VirtuosoHandle } from "react-virtuoso"; +import { ListRange, VirtuosoHandle } from "react-virtuoso"; import { applyRowFn, createRowLookup, @@ -10,6 +10,7 @@ import { isGridCellContent, isJSX, MaybeFn, + shouldSkipScrollTo, } from "src/components/index"; import { GridDataRow } from "src/components/Table/components/Row"; import { DiscriminateUnion, Kinded } from "src/components/Table/types"; @@ -104,6 +105,7 @@ export class GridTableApiImpl implements GridTableApi { // This is public to GridTable but not exported outside of Beam readonly tableState: TableState = new TableState(this); virtuosoRef: MutableRefObject = { current: null }; + virtuosoRangeRef: MutableRefObject = { current: null }; lookup!: GridRowLookup; constructor() { @@ -118,16 +120,29 @@ export class GridTableApiImpl implements GridTableApi { } /** Called once by the GridTable when it takes ownership of this api instance. */ - init(persistCollapse: string | undefined, virtuosoRef: MutableRefObject) { + init( + persistCollapse: string | undefined, + virtuosoRef: MutableRefObject, + virtuosoRangeRef: MutableRefObject, + ) { // Technically this drives both row-collapse and column-expanded if (persistCollapse) this.tableState.loadCollapse(persistCollapse); this.virtuosoRef = virtuosoRef; - this.lookup = createRowLookup(this, virtuosoRef); + this.virtuosoRangeRef = virtuosoRangeRef; + this.lookup = createRowLookup(this, virtuosoRef, virtuosoRangeRef); } - public scrollToIndex(index: GridTableScrollOptions): void { - this.virtuosoRef.current && - this.virtuosoRef.current.scrollToIndex(typeof index === "number" ? { index, behavior: "smooth" } : index); + public scrollToIndex(indexOrOptions: GridTableScrollOptions): void { + if (!this.virtuosoRef.current) return; + + const { forceRescroll = true, ...scrollToOpts } = + typeof indexOrOptions === "number" + ? { index: indexOrOptions, behavior: "smooth" as const, forceRescroll: false } + : indexOrOptions; + + if (shouldSkipScrollTo(scrollToOpts.index, this.virtuosoRangeRef, forceRescroll)) return; + + this.virtuosoRef.current.scrollToIndex(scrollToOpts); } public getSelectedRowIds(kind?: string): string[] { diff --git a/src/components/Table/index.ts b/src/components/Table/index.ts index b0e9b7039..6765e7811 100644 --- a/src/components/Table/index.ts +++ b/src/components/Table/index.ts @@ -19,7 +19,7 @@ export { cardStyle, condensedStyle, defaultStyle, getTableStyles } from "src/com export type { GridStyle, RowStyle, RowStyles } from "src/components/Table/TableStyles"; export * from "src/components/Table/types"; export * from "src/components/Table/utils/columns"; -export { createRowLookup } from "src/components/Table/utils/GridRowLookup"; +export { createRowLookup, shouldSkipScrollTo } from "src/components/Table/utils/GridRowLookup"; export type { GridRowLookup } from "src/components/Table/utils/GridRowLookup"; export { simpleDataRows, simpleHeader } from "src/components/Table/utils/simpleHelpers"; export type { SimpleHeaderAndData } from "src/components/Table/utils/simpleHelpers"; diff --git a/src/components/Table/types.ts b/src/components/Table/types.ts index 121e85cbd..4b40acce2 100644 --- a/src/components/Table/types.ts +++ b/src/components/Table/types.ts @@ -22,6 +22,8 @@ export type GridTableScrollOptions = * How to position the row in the viewport */ align?: "start" | "center" | "end"; + /** Determines if the row should be re-scrolled to even if it's already in view (scrolled to top) */ + forceRescroll?: boolean; }; /** diff --git a/src/components/Table/utils/GridRowLookup.ts b/src/components/Table/utils/GridRowLookup.ts index b9037905c..f8bba4888 100644 --- a/src/components/Table/utils/GridRowLookup.ts +++ b/src/components/Table/utils/GridRowLookup.ts @@ -1,5 +1,5 @@ import { MutableRefObject } from "react"; -import { VirtuosoHandle } from "react-virtuoso"; +import { ListRange, VirtuosoHandle } from "react-virtuoso"; import { GridDataRow } from "src/components/Table/components/Row"; import { GridTableApiImpl } from "src/components/Table/GridTableApi"; import { DiscriminateUnion, GridColumnWithId, Kinded, nonKindGridColumnKeys } from "src/components/Table/types"; @@ -22,8 +22,11 @@ export interface GridRowLookup { /** Returns the list of currently filtered/sorted rows, without headers. */ currentList(): readonly GridDataRow[]; - /** Scroll's to the row with the given kind + id. Requires using `as=virtual`. */ - scrollTo(kind: R["kind"], id: string): void; + /** + * Scroll's to the row with the given kind + id. Requires using `as=virtual`. + * `forceRescroll` controls if an element should be re-scrolled to the top even if it's already visible. (Default false) + */ + scrollTo(kind: R["kind"], id: string, forceRescroll?: boolean): void; } interface NextPrev { @@ -34,15 +37,20 @@ interface NextPrev { export function createRowLookup( api: GridTableApiImpl, virtuosoRef: MutableRefObject, + virtuosoRangeRef: MutableRefObject, ): GridRowLookup { return { - scrollTo(kind, id) { + scrollTo(kind, id, forceRescroll = true) { if (virtuosoRef.current === null) { // In theory we could support as=div and as=table by finding the DOM // element and calling .scrollIntoView, just not doing that yet. throw new Error("scrollTo is only supported for as=virtual"); } + const index = api.tableState.visibleRows.findIndex((r) => r && r.kind === kind && r.row.id === id); + + if (shouldSkipScrollTo(index, virtuosoRangeRef, forceRescroll)) return; + virtuosoRef.current.scrollToIndex({ index, behavior: "smooth" }); }, currentList() { @@ -83,3 +91,20 @@ export function getKinds(columns: GridColumnWithId[]): R[] (key) => !nonKindGridColumnKeys.includes(key), ) as any; } + +/** Optionally takes into consideration if a row is already in view before attempting to scroll to it. */ +export function shouldSkipScrollTo( + index: number, + virtuosoRangeRef: MutableRefObject, + forceRescroll: boolean, +) { + if (!virtuosoRangeRef.current || forceRescroll) return false; + + const isAlreadyInView = + // Add 1 on each end to account for "overscan" where the next out of view row is usually already rendered. This isn't a perfect solution, + // but our current "overscan" is only set to 50px, so it should be close enough and the library recommended alternative of adding an + // intersection observer to each row seems like a not worth it performance hit (https://github.com/petyosi/react-virtuoso/issues/118) + index >= virtuosoRangeRef.current.startIndex - 1 && index <= virtuosoRangeRef.current.endIndex + 1; + + return isAlreadyInView; +}