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 + +
  • 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.