From b9afffe56177f0e0683cd9142a7d55fdb41ea495 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Tue, 5 Jul 2022 18:32:21 +0000
Subject: [PATCH 01/10] Expose scrollTo and scrollToItem on imperative ref
---
src/components/datagrid/data_grid.tsx | 1 +
src/components/datagrid/data_grid_types.ts | 11 +++++++++
src/components/datagrid/utils/ref.ts | 28 +++++++++++++++++++---
3 files changed, 37 insertions(+), 3 deletions(-)
diff --git a/src/components/datagrid/data_grid.tsx b/src/components/datagrid/data_grid.tsx
index 6c105fccee8..00262028ee8 100644
--- a/src/components/datagrid/data_grid.tsx
+++ b/src/components/datagrid/data_grid.tsx
@@ -287,6 +287,7 @@ export const EuiDataGrid = forwardRef(
*/
useImperativeGridRef({
ref,
+ gridRef,
setIsFullScreen,
focusContext,
cellPopoverContext,
diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts
index e07d17fac40..77b134c296b 100644
--- a/src/components/datagrid/data_grid_types.ts
+++ b/src/components/datagrid/data_grid_types.ts
@@ -15,6 +15,7 @@ import {
ReactElement,
AriaAttributes,
MutableRefObject,
+ Component,
} from 'react';
import {
VariableSizeGridProps,
@@ -28,6 +29,8 @@ import { RowHeightUtils } from './utils/row_heights';
import { IconType } from '../icon';
import { EuiTokenProps } from '../token';
+export type ImperativeGridApi = Omit;
+
export interface EuiDataGridToolbarProps {
gridWidth: number;
minSizeForControls?: number;
@@ -358,6 +361,14 @@ export interface EuiDataGridRefProps {
* Closes any currently open popovers in the data grid.
*/
closeCellPopover: () => void;
+ /**
+ * Scrolls to a specified top and left offset.
+ */
+ scrollTo?: ImperativeGridApi['scrollTo'];
+ /**
+ * Scrolls to a specified rowIndex.
+ */
+ scrollToItem?: ImperativeGridApi['scrollToItem'];
}
export interface EuiDataGridColumnResizerProps {
diff --git a/src/components/datagrid/utils/ref.ts b/src/components/datagrid/utils/ref.ts
index 5c4d6a0085e..f3e2409651c 100644
--- a/src/components/datagrid/utils/ref.ts
+++ b/src/components/datagrid/utils/ref.ts
@@ -6,7 +6,8 @@
* Side Public License, v 1.
*/
-import { useImperativeHandle, useCallback, Ref } from 'react';
+import { useImperativeHandle, useCallback, Ref, RefObject } from 'react';
+import type { VariableSizeGrid } from 'react-window';
import {
EuiDataGridRefProps,
EuiDataGridProps,
@@ -17,6 +18,7 @@ import {
interface Dependencies {
ref: Ref;
+ gridRef: RefObject;
setIsFullScreen: EuiDataGridRefProps['setIsFullScreen'];
focusContext: DataGridFocusContextShape;
cellPopoverContext: DataGridCellPopoverContextShape;
@@ -28,6 +30,7 @@ interface Dependencies {
export const useImperativeGridRef = ({
ref,
+ gridRef,
setIsFullScreen,
focusContext,
cellPopoverContext,
@@ -75,16 +78,35 @@ export const useImperativeGridRef = ({
[_openCellPopover, checkCellExists, findVisibleRowIndex]
);
+ const scrollTo = useCallback(
+ (...args) => gridRef.current?.scrollTo(...args),
+ [gridRef]
+ );
+
+ const scrollToItem = useCallback(
+ (...args) => gridRef.current?.scrollToItem(...args),
+ [gridRef]
+ );
+
// Set the ref APIs
useImperativeHandle(
ref,
- () => ({
+ (): EuiDataGridRefProps => ({
setIsFullScreen,
setFocusedCell,
openCellPopover,
closeCellPopover,
+ scrollTo,
+ scrollToItem,
}),
- [setIsFullScreen, setFocusedCell, openCellPopover, closeCellPopover]
+ [
+ setIsFullScreen,
+ setFocusedCell,
+ openCellPopover,
+ closeCellPopover,
+ scrollTo,
+ scrollToItem,
+ ]
);
};
From 1a7b9769f7a29d7714f384ccef447b62d97f2fa1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Tue, 5 Jul 2022 18:34:53 +0000
Subject: [PATCH 02/10] Add method to compensate for layout shifts
---
.../datagrid/body/data_grid_body.tsx | 4 +-
src/components/datagrid/data_grid_types.ts | 8 +
.../datagrid/utils/row_heights.test.ts | 283 ++++++++++++++----
src/components/datagrid/utils/row_heights.ts | 95 ++++--
src/services/hooks/index.ts | 1 +
src/services/hooks/useLatest.ts | 15 +
6 files changed, 328 insertions(+), 78 deletions(-)
create mode 100644 src/services/hooks/useLatest.ts
diff --git a/src/components/datagrid/body/data_grid_body.tsx b/src/components/datagrid/body/data_grid_body.tsx
index cefbeb804e9..07d378dbe1a 100644
--- a/src/components/datagrid/body/data_grid_body.tsx
+++ b/src/components/datagrid/body/data_grid_body.tsx
@@ -386,7 +386,9 @@ export const EuiDataGridBody: FunctionComponent = (
* Heights
*/
const rowHeightUtils = useRowHeightUtils({
- gridRef: gridRef.current,
+ gridRef,
+ outerGridElementRef: outerGridRef,
+ gridItemsRenderedRef: gridItemsRendered,
gridStyles,
columns,
rowHeightsOptions,
diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts
index 77b134c296b..0ce3d60357e 100644
--- a/src/components/datagrid/data_grid_types.ts
+++ b/src/components/datagrid/data_grid_types.ts
@@ -892,6 +892,8 @@ export type EuiDataGridRowHeightOption =
| 'auto'
| ExclusiveUnion<{ lineCount: number }, { height: number }>;
+export type EuiDataGridScrollAnchorRow = 'start' | 'center' | undefined;
+
export interface EuiDataGridRowHeightsOptions {
/**
* Defines the default size for all rows. It can be line count or just height.
@@ -913,6 +915,12 @@ export interface EuiDataGridRowHeightsOptions {
* Can be used for, e.g. storing user `rowHeightsOptions` in a local storage object.
*/
onChange?: (rowHeightsOptions: EuiDataGridRowHeightsOptions) => void;
+ /**
+ * When set to 'start' or 'center' the topmost or middle visible row will try
+ * to compensate for changes in their top offsets by adjust the grid's scroll
+ * position.
+ */
+ scrollAnchorRow?: EuiDataGridScrollAnchorRow;
}
export interface EuiDataGridRowManager {
diff --git a/src/components/datagrid/utils/row_heights.test.ts b/src/components/datagrid/utils/row_heights.test.ts
index dc63ec3d68b..b71a9eb63d6 100644
--- a/src/components/datagrid/utils/row_heights.test.ts
+++ b/src/components/datagrid/utils/row_heights.test.ts
@@ -6,18 +6,45 @@
* Side Public License, v 1.
*/
+import type { MutableRefObject } from 'react';
import { act } from 'react-dom/test-utils';
import { testCustomHook } from '../../../test/internal';
import { startingStyles } from '../controls';
-
+import type { ImperativeGridApi } from '../data_grid_types';
import {
- RowHeightUtils,
cellPaddingsMap,
+ RowHeightUtils,
useRowHeightUtils,
} from './row_heights';
describe('RowHeightUtils', () => {
- const rowHeightUtils = new RowHeightUtils();
+ const gridRef: MutableRefObject = {
+ current: {
+ resetAfterIndices: jest.fn(),
+ resetAfterColumnIndex: jest.fn(),
+ resetAfterRowIndex: jest.fn(),
+ scrollTo: jest.fn(),
+ scrollToItem: jest.fn(),
+ },
+ };
+ const outerGridElementRef = { current: null };
+ const gridItemsRenderedRef = { current: null };
+ const rerenderGridBodyRef = { current: jest.fn() };
+ const rowHeightUtils = new RowHeightUtils(
+ gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
+ rerenderGridBodyRef
+ );
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.resetAllMocks();
+ });
describe('getRowHeightOption', () => {
const rowHeightsOptions = {
@@ -117,8 +144,15 @@ describe('RowHeightUtils', () => {
});
describe('auto height', () => {
- const getRowHeightSpy = jest.spyOn(rowHeightUtils, 'getRowHeight');
- beforeEach(() => getRowHeightSpy.mockClear());
+ let getRowHeightSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ getRowHeightSpy = jest.spyOn(rowHeightUtils, 'getRowHeight');
+ });
+
+ afterEach(() => {
+ getRowHeightSpy.mockRestore();
+ });
it('gets the max height for the current row from the heights cache', () => {
expect(rowHeightUtils.getCalculatedHeight('auto', 34, 1)).toEqual(0); // 0 is expected since the cache is empty
@@ -212,13 +246,19 @@ describe('RowHeightUtils', () => {
});
describe('calculateHeightForLineCount', () => {
+ let getComputedStyleSpy: jest.SpyInstance;
+ const cell = document.createElement('div');
+
beforeEach(() => {
rowHeightUtils.cacheStyles({ cellPadding: 'm' });
- jest
+ getComputedStyleSpy = jest
.spyOn(window, 'getComputedStyle')
.mockReturnValue({ lineHeight: '24px' } as CSSStyleDeclaration);
});
- const cell = document.createElement('div');
+
+ afterEach(() => {
+ getComputedStyleSpy.mockRestore();
+ });
it('calculates the row height based on the number of lines and cell line height/padding', () => {
expect(rowHeightUtils.calculateHeightForLineCount(cell, 1)).toEqual(36); // 1 * 24 + 6 + 6
@@ -275,10 +315,17 @@ describe('RowHeightUtils', () => {
});
describe('row height cache', () => {
- describe('setRowHeight', () => {
- const resetRowSpy = jest.spyOn(rowHeightUtils, 'resetRow');
- beforeEach(() => resetRowSpy.mockClear());
+ let resetRowSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ resetRowSpy = jest.spyOn(rowHeightUtils, 'resetRow');
+ });
+
+ afterEach(() => {
+ resetRowSpy.mockRestore();
+ });
+ describe('setRowHeight', () => {
it('setRowHeight', () => {
rowHeightUtils.setRowHeight(5, 'a', 50, 0);
rowHeightUtils.setRowHeight(5, 'b', 34, 0);
@@ -302,11 +349,9 @@ describe('RowHeightUtils', () => {
});
it('calls rerenderGridBody', () => {
- const rerenderGridBody = jest.fn();
- rowHeightUtils.setRerenderGridBody(rerenderGridBody);
- expect(rerenderGridBody).toHaveBeenCalledTimes(0);
+ expect(rerenderGridBodyRef.current).toHaveBeenCalledTimes(0);
rowHeightUtils.setRowHeight(1, 'a', 34, 1);
- expect(rerenderGridBody).toHaveBeenCalledTimes(1);
+ expect(rerenderGridBodyRef.current).toHaveBeenCalledTimes(1);
});
});
@@ -321,9 +366,6 @@ describe('RowHeightUtils', () => {
});
describe('pruneHiddenColumnHeights', () => {
- const resetRowSpy = jest.spyOn(rowHeightUtils, 'resetRow');
- beforeEach(() => resetRowSpy.mockClear());
-
it('checks each row height map and deletes column IDs that are no longer visible', () => {
rowHeightUtils.pruneHiddenColumnHeights([{ id: 'a' }, { id: 'b' }]);
expect(rowHeightUtils.getRowHeight(5)).toEqual(62);
@@ -343,21 +385,16 @@ describe('RowHeightUtils', () => {
});
describe('grid resetting', () => {
- const mockGrid = { resetAfterRowIndex: jest.fn() } as any;
- beforeEach(() => jest.clearAllMocks());
-
- describe('setGrid', () => {
- it('stores the react-window grid as an instance variable', () => {
- rowHeightUtils.setGrid(mockGrid);
+ describe('resetRow', () => {
+ let resetGridSpy: jest.SpyInstance;
- // @ts-ignore this var is private, but we're inspecting it for the sake of the unit test
- expect(rowHeightUtils.grid).toEqual(mockGrid);
+ beforeEach(() => {
+ resetGridSpy = jest.spyOn(rowHeightUtils, 'resetGrid');
});
- });
- describe('resetRow', () => {
- const resetGridSpy = jest.spyOn(rowHeightUtils, 'resetGrid');
- jest.useFakeTimers();
+ afterEach(() => {
+ resetGridSpy.mockRestore();
+ });
it('sets this.lastUpdatedRow and resets the grid', () => {
rowHeightUtils.resetRow(0);
@@ -374,54 +411,185 @@ describe('RowHeightUtils', () => {
describe('resetGrid', () => {
it('invokes grid.resetAfterRowIndex with the last visible row', () => {
rowHeightUtils.setRowHeight(99, 'a', 34, 99);
- rowHeightUtils.resetGrid();
- expect(mockGrid.resetAfterRowIndex).toHaveBeenCalledWith(99);
+
+ jest.runAllTimers();
+
+ expect(gridRef.current?.resetAfterRowIndex).toHaveBeenCalledWith(99);
});
it('invokes resetAfterRowIndex only once with the smallest cached row index', () => {
- rowHeightUtils.setRowHeight(97, 'a', 34, 97);
- rowHeightUtils.setRowHeight(99, 'a', 34, 99);
- rowHeightUtils.resetGrid();
- expect(mockGrid.resetAfterRowIndex).toHaveBeenCalledTimes(1);
- expect(mockGrid.resetAfterRowIndex).toHaveBeenCalledWith(97);
+ rowHeightUtils.setRowHeight(97, 'a', 35, 97);
+ rowHeightUtils.setRowHeight(99, 'a', 36, 99);
+
+ jest.runAllTimers();
+
+ expect(gridRef.current?.resetAfterRowIndex).toHaveBeenCalledTimes(1);
+ expect(gridRef.current?.resetAfterRowIndex).toHaveBeenCalledWith(97);
});
});
});
+
+ describe('layout shift compensation', () => {
+ it('can compensate vertical shifts of the start anchor row', () => {
+ const rowHeightUtils = new RowHeightUtils(
+ gridRef,
+ {
+ current: {
+ scrollTop: 100,
+ } as any,
+ },
+ {
+ current: {
+ overscanRowStartIndex: 1,
+ overscanRowStopIndex: 12,
+ overscanColumnStartIndex: 0,
+ overscanColumnStopIndex: 1,
+ visibleRowStartIndex: 2,
+ visibleRowStopIndex: 11,
+ visibleColumnStartIndex: 0,
+ visibleColumnStopIndex: 1,
+ },
+ },
+ rerenderGridBodyRef
+ );
+
+ // the center row shifted by 10 pixels
+ rowHeightUtils.compensateForLayoutShift(4, 10, 'start');
+
+ // no scrolling should have taken place
+ expect(gridRef.current?.scrollTo).toHaveBeenCalledTimes(0);
+
+ // the anchor row shifted by 23 pixels
+ rowHeightUtils.compensateForLayoutShift(2, 23, 'start');
+
+ // the grid should have scrolled accordingly
+ expect(gridRef.current?.scrollTo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ scrollTop: 123,
+ })
+ );
+ });
+
+ it('can compensate vertical shifts of the center anchor row', () => {
+ const rowHeightUtils = new RowHeightUtils(
+ gridRef,
+ {
+ current: {
+ scrollTop: 100,
+ } as any,
+ },
+ {
+ current: {
+ overscanRowStartIndex: 1,
+ overscanRowStopIndex: 12,
+ overscanColumnStartIndex: 0,
+ overscanColumnStopIndex: 1,
+ visibleRowStartIndex: 2,
+ visibleRowStopIndex: 11,
+ visibleColumnStartIndex: 0,
+ visibleColumnStopIndex: 1,
+ },
+ },
+ rerenderGridBodyRef
+ );
+
+ // the topmost visible row shifted by 10 pixels
+ rowHeightUtils.compensateForLayoutShift(2, 10, 'center');
+
+ // no scrolling should have taken place
+ expect(gridRef.current?.scrollTo).toHaveBeenCalledTimes(0);
+
+ // the anchor row shifted by 23 pixels
+ rowHeightUtils.compensateForLayoutShift(4, 23, 'center');
+
+ // the grid should have scrolled accordingly
+ expect(gridRef.current?.scrollTo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ scrollTop: 123,
+ })
+ );
+ });
+
+ it("doesn't compensate vertical shifts when no anchor row is specified", () => {
+ const rowHeightUtils = new RowHeightUtils(
+ gridRef,
+ {
+ current: {
+ scrollTop: 100,
+ } as any,
+ },
+ {
+ current: {
+ overscanRowStartIndex: 1,
+ overscanRowStopIndex: 12,
+ overscanColumnStartIndex: 0,
+ overscanColumnStopIndex: 1,
+ visibleRowStartIndex: 2,
+ visibleRowStopIndex: 11,
+ visibleColumnStartIndex: 0,
+ visibleColumnStopIndex: 1,
+ },
+ },
+ rerenderGridBodyRef
+ );
+
+ // the topmost visible row shifted by 23 pixels, but no anchor has been specified
+ rowHeightUtils.compensateForLayoutShift(2, 23, undefined);
+
+ // no scrolling should have taken place
+ expect(gridRef.current?.scrollTo).toHaveBeenCalledTimes(0);
+ });
+ });
});
});
describe('useRowHeightUtils', () => {
+ const gridRef: MutableRefObject = {
+ current: {
+ resetAfterIndices: jest.fn(),
+ resetAfterColumnIndex: jest.fn(),
+
+ resetAfterRowIndex: jest.fn(),
+ scrollTo: jest.fn(),
+ scrollToItem: jest.fn(),
+ },
+ };
+ const outerGridElementRef = { current: null };
+ const gridItemsRenderedRef = { current: null };
+
const mockArgs = {
- gridRef: null,
+ gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
gridStyles: startingStyles,
columns: [{ id: 'A' }, { id: 'B' }],
rowHeightOptions: undefined,
};
+ let requestAnimationFrameSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ requestAnimationFrameSpy = jest
+ .spyOn(window, 'requestAnimationFrame')
+ .mockImplementation((cb: any) => cb());
+ });
+
+ afterEach(() => {
+ requestAnimationFrameSpy.mockRestore();
+ });
+
it('instantiates and returns an instance of RowHeightUtils', () => {
- const { return: rowHeightUtils } = testCustomHook(() =>
+ const { return: rowHeightUtils } = testCustomHook(() =>
useRowHeightUtils(mockArgs)
);
expect(rowHeightUtils).toBeInstanceOf(RowHeightUtils);
});
- it('populates internal RowHeightUtils vars from outside dependencies', () => {
- const args = { ...mockArgs, gridRef: {} as any };
- const { return: rowHeightUtils } = testCustomHook(() =>
- useRowHeightUtils(args)
- );
- // @ts-ignore - intentionally inspecting private var for test
- expect(rowHeightUtils.grid).toEqual(args.gridRef);
- // @ts-ignore - intentionally inspecting private var for test
- expect(rowHeightUtils.rerenderGridBody).toBeInstanceOf(Function);
- });
-
it('forces a rerender every time rowHeightsOptions changes', () => {
- const requestAnimationFrameSpy = jest
- .spyOn(window, 'requestAnimationFrame')
- .mockImplementation((cb: any) => cb());
-
- const { updateHookArgs } = testCustomHook(useRowHeightUtils, mockArgs);
+ const { updateHookArgs } = testCustomHook(
+ useRowHeightUtils,
+ mockArgs
+ );
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
updateHookArgs({ rowHeightsOptions: { defaultHeight: 300 } });
@@ -434,10 +602,9 @@ describe('useRowHeightUtils', () => {
});
it('updates internal cached styles whenever gridStyle.cellPadding changes', () => {
- const { return: rowHeightUtils, updateHookArgs } = testCustomHook(
- useRowHeightUtils,
- mockArgs
- );
+ const { return: rowHeightUtils, updateHookArgs } = testCustomHook<
+ RowHeightUtils
+ >(useRowHeightUtils, mockArgs);
updateHookArgs({ gridStyles: { ...startingStyles, cellPadding: 's' } });
// @ts-ignore - intentionally inspecting private var for test
diff --git a/src/components/datagrid/utils/row_heights.ts b/src/components/datagrid/utils/row_heights.ts
index 6d313fcf4a4..4c8ec26d093 100644
--- a/src/components/datagrid/utils/row_heights.ts
+++ b/src/components/datagrid/utils/row_heights.ts
@@ -13,16 +13,22 @@ import {
useCallback,
useContext,
CSSProperties,
+ MutableRefObject,
+ Component,
} from 'react';
-import type { VariableSizeGrid as Grid } from 'react-window';
+import type {
+ GridOnItemsRenderedProps,
+ VariableSizeGrid as Grid,
+} from 'react-window';
import { isObject, isNumber } from '../../../services/predicate';
-import { useForceRender } from '../../../services';
+import { useForceRender, useLatest } from '../../../services';
import {
EuiDataGridStyleCellPaddings,
EuiDataGridStyle,
EuiDataGridRowHeightOption,
EuiDataGridRowHeightsOptions,
EuiDataGridColumn,
+ EuiDataGridScrollAnchorRow,
} from '../data_grid_types';
import { DataGridSortingContext } from './sorting';
@@ -36,7 +42,16 @@ export const cellPaddingsMap: Record = {
export const AUTO_HEIGHT = 'auto';
export const DEFAULT_ROW_HEIGHT = 34;
+type IGrid = Omit;
+
export class RowHeightUtils {
+ constructor(
+ private gridRef: MutableRefObject,
+ private outerGridElementRef: MutableRefObject,
+ private gridItemsRenderedRef: MutableRefObject,
+ private rerenderGridBodyRef: MutableRefObject<(() => void) | null>
+ ) {}
+
getRowHeightOption(
rowIndex: number,
rowHeightsOptions?: EuiDataGridRowHeightsOptions
@@ -157,9 +172,7 @@ export class RowHeightUtils {
private heightsCache = new Map>();
private timerId?: number;
- private grid?: Grid;
private lastUpdatedRow: number = Infinity;
- private rerenderGridBody: Function = () => {};
isAutoHeight(
rowIndex: number,
@@ -205,7 +218,7 @@ export class RowHeightUtils {
// When an auto row height is updated, force a re-render
// of the grid body to update the unconstrained height
- this.rerenderGridBody();
+ this.rerenderGridBodyRef.current?.();
}
pruneHiddenColumnHeights(visibleColumns: EuiDataGridColumn[]) {
@@ -236,16 +249,52 @@ export class RowHeightUtils {
}
resetGrid() {
- this.grid?.resetAfterRowIndex(this.lastUpdatedRow);
+ this.gridRef.current?.resetAfterRowIndex(this.lastUpdatedRow);
this.lastUpdatedRow = Infinity;
}
- setGrid(grid: Grid) {
- this.grid = grid;
- }
+ compensateForLayoutShift(
+ rowIndex: number,
+ verticalLayoutShift: number,
+ anchorRow: EuiDataGridScrollAnchorRow
+ ) {
+ const grid = this.gridRef.current;
+ const outerGridElement = this.outerGridElementRef.current;
+ const renderedItems = this.gridItemsRenderedRef.current;
+
+ if (
+ grid == null ||
+ outerGridElement == null ||
+ renderedItems == null ||
+ anchorRow == null ||
+ !Number.isFinite(verticalLayoutShift)
+ ) {
+ return;
+ }
- setRerenderGridBody(rerenderGridBody: Function) {
- this.rerenderGridBody = rerenderGridBody;
+ // skip if the start row is the anchor row but it hasn't shifted
+ if (
+ anchorRow === 'start' &&
+ renderedItems.visibleRowStartIndex !== rowIndex
+ ) {
+ return;
+ }
+
+ // skip if the center row is the anchor row but it hasn't shifted
+ if (
+ anchorRow === 'center' &&
+ Math.floor(
+ (renderedItems.visibleRowStopIndex -
+ renderedItems.visibleRowStartIndex) /
+ 2
+ ) !== rowIndex
+ ) {
+ return;
+ }
+
+ grid.scrollTo({
+ scrollTop: outerGridElement.scrollTop + verticalLayoutShift,
+ });
}
}
@@ -255,23 +304,31 @@ export class RowHeightUtils {
*/
export const useRowHeightUtils = ({
gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
gridStyles,
columns,
rowHeightsOptions,
}: {
- gridRef: Grid | null;
+ gridRef: MutableRefObject;
+ outerGridElementRef: MutableRefObject;
+ gridItemsRenderedRef: MutableRefObject;
gridStyles: EuiDataGridStyle;
columns: EuiDataGridColumn[];
rowHeightsOptions?: EuiDataGridRowHeightsOptions;
}) => {
- const rowHeightUtils = useMemo(() => new RowHeightUtils(), []);
-
- // Update rowHeightUtils with internal vars from outside dependencies
const forceRender = useForceRender();
- useEffect(() => {
- if (gridRef) rowHeightUtils.setGrid(gridRef);
- rowHeightUtils.setRerenderGridBody(forceRender);
- }, [gridRef, forceRender, rowHeightUtils]);
+ const forceRenderRef = useLatest(forceRender);
+ const rowHeightUtils = useMemo(
+ () =>
+ new RowHeightUtils(
+ gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
+ forceRenderRef
+ ),
+ [forceRenderRef, gridItemsRenderedRef, gridRef, outerGridElementRef]
+ );
// Forces a rerender whenever the row heights change, as this can cause the
// grid to change height/have scrollbars. Without this, grid rerendering is stale
diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts
index 6f7778cc4f0..16243548f85 100644
--- a/src/services/hooks/index.ts
+++ b/src/services/hooks/index.ts
@@ -10,5 +10,6 @@ export * from './useDependentState';
export * from './useCombinedRefs';
export * from './useForceRender';
export * from './useIsWithinBreakpoints';
+export * from './useLatest';
export * from './useMouseMove';
export * from './useUpdateEffect';
diff --git a/src/services/hooks/useLatest.ts b/src/services/hooks/useLatest.ts
new file mode 100644
index 00000000000..d56f7730bee
--- /dev/null
+++ b/src/services/hooks/useLatest.ts
@@ -0,0 +1,15 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { MutableRefObject, useRef } from 'react';
+
+export function useLatest(value: Value): MutableRefObject {
+ const latestValueRef = useRef(value);
+ latestValueRef.current = value;
+ return latestValueRef;
+}
From be6c40ac030e93029b5b9ef9c90e8f36d8a355a4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Wed, 6 Jul 2022 14:24:55 +0000
Subject: [PATCH 03/10] Trigger layout shift compensation from cells
---
src/components/datagrid/body/data_grid_cell.tsx | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx
index c8938e512a7..a03be2b7483 100644
--- a/src/components/datagrid/body/data_grid_cell.tsx
+++ b/src/components/datagrid/body/data_grid_cell.tsx
@@ -291,6 +291,21 @@ export class EuiDataGridCell extends Component<
this.recalculateLineHeight();
}
+ if (
+ this.props.colIndex === 0 && // once per row
+ this.props.columnId === prevProps.columnId && // if this is still the same column
+ this.props.rowIndex === prevProps.rowIndex && // if this is still the same row
+ this.props.style?.top !== prevProps.style?.top // if the top position has changed
+ ) {
+ const previousTop = parseFloat(prevProps.style?.top as string);
+ const currentTop = parseFloat(this.props.style?.top as string);
+ this.props.rowHeightUtils?.compensateForLayoutShift(
+ this.props.rowIndex,
+ currentTop - previousTop,
+ this.props.rowHeightsOptions?.scrollAnchorRow
+ );
+ }
+
if (
this.props.popoverContext.popoverIsOpen !==
prevProps.popoverContext.popoverIsOpen ||
From 016cf98b3d242d4200f0dcf247629268ae5bfb08 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Wed, 6 Jul 2022 14:25:23 +0000
Subject: [PATCH 04/10] Add example to data grid docs
---
.../advanced/datagrid_advanced_example.js | 17 +
.../datagrid/advanced/scroll_anchoring.tsx | 554 ++++++++++++++++++
2 files changed, 571 insertions(+)
create mode 100644 src-docs/src/views/datagrid/advanced/scroll_anchoring.tsx
diff --git a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js
index e978a551224..f61ca821482 100644
--- a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js
+++ b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js
@@ -25,6 +25,8 @@ dataGridRef.current.openCellPopover({ rowIndex, colIndex });
dataGridRef.current.closeCellPopover();
`;
+import DataGridScrollAnchoring from './scroll_anchoring';
+
export const DataGridAdvancedExample = {
title: 'Data grid advanced',
sections: [
@@ -118,5 +120,20 @@ export const DataGridAdvancedExample = {
props: { EuiDataGridRefProps },
},
...DataGridMemoryExample.sections,
+ {
+ title: 'Scrolling and incremental loading',
+ text: (
+ <>
+
+ This example shows a grid that performs incremental loading when
+ either end is reached. It also attempts to minimize the visible
+ layout shift when newly rendered items are measured and sized for
+ the first time.
+
+ Observe the layout shift compensation effect when scrolling up to
+ previously unviewed rows with and without the "Use scroll
+ anchor" toggle enabled.
+ Optional indicator of the row that should be used as an
+ anchor for vertical layout shift compensation.
+
+
+ Can be set to the default undefined,
+ "start", or
+ "center".
+
+
+ If set to "start", the topmost
+ visible row will monitor for unexpected changes to its
+ vertical position and try to compensate for these by
+ scrolling the grid scroll container such that the topmost
+ row position remains stable.
+
+
+ If set to "center", the middle
+ visible row will monitor for unexpected changes to its
+ vertical position and try to compensate for these by
+ scrolling the grid scroll container such that the middle row
+ position remains stable.
+
+
+ This is particularly useful when the grid contains
+ auto sized rows. Since these rows are
+ measured as they appear in the overscan, they can cause
+ surprising shifts of the vertical position of all following
+ rows when their measure height is different from the
+ estimated height. See the{' '}
+
+ advanced data grid scrolling and incremental loading
+ example{' '}
+
+ .
+
+
+
From 74b640bdfad7cd5415103bd1978d01cc36985579 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Wed, 6 Jul 2022 14:50:47 +0000
Subject: [PATCH 06/10] Add changelog entry
---
upcoming_changelogs/6028.md | 2 ++
1 file changed, 2 insertions(+)
create mode 100644 upcoming_changelogs/6028.md
diff --git a/upcoming_changelogs/6028.md b/upcoming_changelogs/6028.md
new file mode 100644
index 00000000000..f3458446122
--- /dev/null
+++ b/upcoming_changelogs/6028.md
@@ -0,0 +1,2 @@
+- The `EuiDatGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows.
+
From 02a836a2100c427a9d69a694646c074b287f4baf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Wed, 6 Jul 2022 15:12:24 +0000
Subject: [PATCH 07/10] Mention scrollTo in the changelog
---
upcoming_changelogs/6028.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/upcoming_changelogs/6028.md b/upcoming_changelogs/6028.md
index f3458446122..ea6ad34652b 100644
--- a/upcoming_changelogs/6028.md
+++ b/upcoming_changelogs/6028.md
@@ -1,2 +1,2 @@
-- The `EuiDatGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows.
-
+- The `EuiDataGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows.
+- The `EuiDataGrid`'s imperative API now exposes the `scrollTo` and `scrollToItem` APIs of `react-window`.
From 8ab0220a78d9ab0560ad260a3a8e933f22848111 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Wed, 6 Jul 2022 15:12:45 +0000
Subject: [PATCH 08/10] Add `scrollAnchorRow` to the props snippets
---
src-docs/src/views/datagrid/_snippets.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/src-docs/src/views/datagrid/_snippets.tsx b/src-docs/src/views/datagrid/_snippets.tsx
index d377ee9f7f3..57097112053 100644
--- a/src-docs/src/views/datagrid/_snippets.tsx
+++ b/src-docs/src/views/datagrid/_snippets.tsx
@@ -97,6 +97,7 @@ inMemory={{ level: 'sorting' }}`,
4: 200, // row at index 4 will adjust the height to 200px
6: 'auto', // row at index 6 will automatically adjust the height
},
+ scrollAnchorRow: 'start', // compensate for layout shift when auto-sized rows are scrolled into view
}}`,
ref: `// Optional. For advanced control of internal data grid state, passes back an object of imperative API methods
ref={dataGridRef}`,
From 9745bb5b5219cd88ca4ff534b18f02b6530c2e3a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Felix=20St=C3=BCrmer?=
Date: Wed, 6 Jul 2022 15:24:50 +0000
Subject: [PATCH 09/10] Add documentation for `scrollTo` and `scrollToItem`
---
.../advanced/datagrid_advanced_example.js | 21 +++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js
index 66086894c02..8fb59896eec 100644
--- a/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js
+++ b/src-docs/src/views/datagrid/advanced/datagrid_advanced_example.js
@@ -81,6 +81,27 @@ export const DataGridAdvancedExample = {
open cell popover.
+
+
+
+ scrollTo({'{ scrollLeft: number, scrollTop: number }'})
+ {' '}
+ - scrolls the grid to the specified horizontal and vertical
+ pixel offsets.
+
+
+
+
+
+ scrollToItem(
+ {
+ '{align: string = "auto", columnIndex?: number, rowIndex?: number }'
+ }
+ )
+ {' '}
+ - scrolls the grid to the specified row and columns indices
+