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}`,
diff --git a/src-docs/src/views/datagrid/basics/virtualization.js b/src-docs/src/views/datagrid/basics/virtualization.js
index d452ca96030..8d4a7c6e10e 100644
--- a/src-docs/src/views/datagrid/basics/virtualization.js
+++ b/src-docs/src/views/datagrid/basics/virtualization.js
@@ -194,6 +194,10 @@ export default () => {
onChangeItemsPerPage: onChangeItemsPerPage,
onChangePage: onChangePage,
}}
+ rowHeightsOptions={{
+ defaultHeight: 'auto',
+ scrollAnchorRow: 'start',
+ }}
/>
>
diff --git a/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js b/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js
index d1c3fb48a5d..f7091662339 100644
--- a/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js
+++ b/src-docs/src/views/datagrid/styling/datagrid_height_options_example.js
@@ -169,6 +169,42 @@ export const dataGridRowHeightOptionsExample = {
+
+ scrollAnchorRow
+
+ -
+ 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 measured height is different from the
+ estimated height.
+
+
+
diff --git a/src/components/datagrid/body/data_grid_body.test.tsx b/src/components/datagrid/body/data_grid_body.test.tsx
index e3c7488258a..f0a551d2201 100644
--- a/src/components/datagrid/body/data_grid_body.test.tsx
+++ b/src/components/datagrid/body/data_grid_body.test.tsx
@@ -21,9 +21,15 @@ describe('EuiDataGridBody', () => {
resetAfterRowIndex: jest.fn(),
} as any,
};
+ const outerGridElementRef = { current: null };
const gridItemsRendered = { current: null };
const rerenderGridBodyRef = { current: null };
- const rowHeightUtils = new RowHeightUtils(gridRef, rerenderGridBodyRef);
+ const rowHeightUtils = new RowHeightUtils(
+ gridRef,
+ outerGridElementRef,
+ gridItemsRendered,
+ rerenderGridBodyRef
+ );
const requiredProps = {
headerIsInteractive: true,
diff --git a/src/components/datagrid/body/data_grid_body.tsx b/src/components/datagrid/body/data_grid_body.tsx
index c51e9bc4e0f..3ca0c4f9d11 100644
--- a/src/components/datagrid/body/data_grid_body.tsx
+++ b/src/components/datagrid/body/data_grid_body.tsx
@@ -389,6 +389,8 @@ export const EuiDataGridBody: FunctionComponent = (
*/
const rowHeightUtils = useRowHeightUtils({
gridRef,
+ outerGridElementRef: outerGridRef,
+ gridItemsRenderedRef: gridItemsRendered,
gridStyles,
columns,
rowHeightsOptions,
diff --git a/src/components/datagrid/body/data_grid_cell.test.tsx b/src/components/datagrid/body/data_grid_cell.test.tsx
index 799a9e398da..de149228cd6 100644
--- a/src/components/datagrid/body/data_grid_cell.test.tsx
+++ b/src/components/datagrid/body/data_grid_cell.test.tsx
@@ -17,6 +17,8 @@ import { EuiDataGridCell } from './data_grid_cell';
describe('EuiDataGridCell', () => {
const mockRowHeightUtils = new RowHeightUtils(
+ { current: null },
+ { current: null },
{ current: null },
{ current: null }
);
@@ -235,6 +237,58 @@ describe('EuiDataGridCell', () => {
);
expect(popoverContent).toMatchSnapshot();
});
+
+ describe('rowHeightsOptions.scrollAnchorRow', () => {
+ let component: ReactWrapper;
+
+ beforeEach(() => {
+ component = mount(
+
+ );
+ });
+
+ it('compensates for layout shifts', () => {
+ component.setProps({ style: { top: '60px' } });
+ expect(
+ mockRowHeightUtils.compensateForLayoutShift
+ ).toHaveBeenCalledWith(0, 30, 'start');
+ });
+
+ describe('does not compensate for layout shifts when', () => {
+ afterEach(() => {
+ expect(
+ mockRowHeightUtils.compensateForLayoutShift
+ ).not.toHaveBeenCalled();
+ });
+
+ test('the rowIndex is changing', () => {
+ component.setProps({ style: '60px', rowIndex: 3 });
+ });
+
+ test('the columnId is changing', () => {
+ component.setProps({ style: '60px', columnId: 'someOtherColumn' });
+ });
+
+ test('scrollAnchorRow is undefined', () => {
+ component.setProps({ rowHeightsOptions: { defaultHeight: 20 } });
+ });
+
+ test('the cell is not the first cell in the row', () => {
+ component.setProps({ colIndex: 1 });
+ });
+
+ test('the cell top position is not changing', () => {
+ component.setProps({ style: { top: '30px' } });
+ });
+ });
+ });
});
describe('componentDidMount', () => {
diff --git a/src/components/datagrid/body/data_grid_cell.tsx b/src/components/datagrid/body/data_grid_cell.tsx
index 5810e9d21ea..866a77e8cbd 100644
--- a/src/components/datagrid/body/data_grid_cell.tsx
+++ b/src/components/datagrid/body/data_grid_cell.tsx
@@ -298,6 +298,22 @@ export class EuiDataGridCell extends Component<
this.recalculateLineHeight();
}
+ if (
+ this.props.rowHeightsOptions?.scrollAnchorRow &&
+ 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 ||
diff --git a/src/components/datagrid/data_grid_types.ts b/src/components/datagrid/data_grid_types.ts
index 33462e4c857..f6cfb0d0a2e 100644
--- a/src/components/datagrid/data_grid_types.ts
+++ b/src/components/datagrid/data_grid_types.ts
@@ -890,6 +890,8 @@ export type EuiDataGridOnColumnResizeHandler = (
data: EuiDataGridOnColumnResizeData
) => void;
+export type EuiDataGridScrollAnchorRow = 'start' | 'center' | undefined;
+
export type EuiDataGridRowHeightOption =
| number
| 'auto'
@@ -916,6 +918,13 @@ export interface EuiDataGridRowHeightsOptions {
* Can be used for, e.g. storing user `rowHeightsOptions` in a local storage object.
*/
onChange?: (rowHeightsOptions: EuiDataGridRowHeightsOptions) => void;
+ /**
+ * Optional indicator of the row that should be used as an anchor for vertical layout shift compensation.
+ * When set to 'start' or 'center', the topmost or middle visible row will try
+ * to compensate for changes in their top offsets by adjusting the grid's scroll
+ * position.
+ */
+ scrollAnchorRow?: EuiDataGridScrollAnchorRow;
}
export interface EuiDataGridRowManager {
diff --git a/src/components/datagrid/utils/__mocks__/row_heights.ts b/src/components/datagrid/utils/__mocks__/row_heights.ts
index 3eea193867a..79d93d2af4b 100644
--- a/src/components/datagrid/utils/__mocks__/row_heights.ts
+++ b/src/components/datagrid/utils/__mocks__/row_heights.ts
@@ -39,6 +39,7 @@ export const RowHeightUtils = jest
isRowHeightOverride: jest.fn(rowHeightUtils.isRowHeightOverride),
resetRow: jest.fn(),
resetGrid: jest.fn(),
+ compensateForLayoutShift: jest.fn(),
};
return (rowHeightUtilsMock as any) as ActualRowHeightUtils;
diff --git a/src/components/datagrid/utils/row_heights.test.ts b/src/components/datagrid/utils/row_heights.test.ts
index 7b541888d5a..34f0d259cbf 100644
--- a/src/components/datagrid/utils/row_heights.test.ts
+++ b/src/components/datagrid/utils/row_heights.test.ts
@@ -27,8 +27,15 @@ describe('RowHeightUtils', () => {
scrollToItem: jest.fn(),
},
};
+ const outerGridElementRef = { current: null };
+ const gridItemsRenderedRef = { current: null };
const rerenderGridBodyRef = { current: jest.fn() };
- const rowHeightUtils = new RowHeightUtils(gridRef, rerenderGridBodyRef);
+ const rowHeightUtils = new RowHeightUtils(
+ gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
+ rerenderGridBodyRef
+ );
beforeEach(() => {
jest.useFakeTimers();
@@ -422,6 +429,118 @@ describe('RowHeightUtils', () => {
});
});
});
+
+ 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);
+ });
+ });
});
});
diff --git a/src/components/datagrid/utils/row_heights.ts b/src/components/datagrid/utils/row_heights.ts
index d5556ad6ee8..e4b3eb181e1 100644
--- a/src/components/datagrid/utils/row_heights.ts
+++ b/src/components/datagrid/utils/row_heights.ts
@@ -7,22 +7,24 @@
*/
import {
- useEffect,
- useState,
- useMemo,
- useCallback,
- useContext,
CSSProperties,
MutableRefObject,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
} from 'react';
-import { isObject, isNumber } from '../../../services/predicate';
+import { GridOnItemsRenderedProps } from 'react-window';
import { useForceRender, useLatest } from '../../../services';
+import { isNumber, isObject } from '../../../services/predicate';
import {
- EuiDataGridStyleCellPaddings,
- EuiDataGridStyle,
+ EuiDataGridColumn,
EuiDataGridRowHeightOption,
EuiDataGridRowHeightsOptions,
- EuiDataGridColumn,
+ EuiDataGridScrollAnchorRow,
+ EuiDataGridStyle,
+ EuiDataGridStyleCellPaddings,
ImperativeGridApi,
} from '../data_grid_types';
import { DataGridSortingContext } from './sorting';
@@ -40,6 +42,8 @@ export const DEFAULT_ROW_HEIGHT = 34;
export class RowHeightUtils {
constructor(
private gridRef: MutableRefObject,
+ private outerGridElementRef: MutableRefObject,
+ private gridItemsRenderedRef: MutableRefObject,
private rerenderGridBodyRef: MutableRefObject<(() => void) | null>
) {}
@@ -243,6 +247,50 @@ export class RowHeightUtils {
this.gridRef.current?.resetAfterRowIndex(this.lastUpdatedRow);
this.lastUpdatedRow = Infinity;
}
+
+ 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;
+ }
+
+ // 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,
+ });
+ }
}
/**
@@ -251,18 +299,28 @@ export class RowHeightUtils {
*/
export const useRowHeightUtils = ({
gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
gridStyles,
columns,
rowHeightsOptions,
}: {
gridRef: MutableRefObject;
+ outerGridElementRef: MutableRefObject;
+ gridItemsRenderedRef: MutableRefObject;
gridStyles: EuiDataGridStyle;
columns: EuiDataGridColumn[];
rowHeightsOptions?: EuiDataGridRowHeightsOptions;
}) => {
const forceRenderRef = useLatest(useForceRender());
const [rowHeightUtils] = useState(
- () => new RowHeightUtils(gridRef, forceRenderRef)
+ () =>
+ new RowHeightUtils(
+ gridRef,
+ outerGridElementRef,
+ gridItemsRenderedRef,
+ forceRenderRef
+ )
);
// Forces a rerender whenever the row heights change, as this can cause the
diff --git a/upcoming_changelogs/6070.md b/upcoming_changelogs/6070.md
new file mode 100644
index 00000000000..df5feef929c
--- /dev/null
+++ b/upcoming_changelogs/6070.md
@@ -0,0 +1 @@
+- The `EuiDataGrid`'s `rowHeightOptions` now contain an optional `scrollAnchorRow` property, which enables vertical layout shift compensation when rendering `auto`-sized rows.