From 51832f043f29bf8e83af69169e51b33ecf80c552 Mon Sep 17 00:00:00 2001 From: yared tsegaye <34584266+yaredtsy@users.noreply.github.com> Date: Fri, 21 Jul 2023 22:51:46 +0300 Subject: [PATCH] [DataGrid] Keep focused cell in the DOM (#7357) Signed-off-by: yared tsegaye <34584266+yaredtsy@users.noreply.github.com> Signed-off-by: Rom Grk Co-authored-by: Yared Tsegaye Co-authored-by: yaredtsyg <138383799+yaredtsyg@users.noreply.github.com> Co-authored-by: Rom Grk Co-authored-by: Andrew Cherniavskii --- .../features/rowReorder/useGridRowReorder.tsx | 2 + .../x-data-grid/src/components/GridRow.tsx | 75 +++++++++--- .../src/components/cell/GridCell.tsx | 52 ++++++--- .../virtualization/useGridVirtualScroller.tsx | 109 +++++++++++++++++- .../src/tests/cells.DataGrid.test.tsx | 34 +++++- 5 files changed, 234 insertions(+), 38 deletions(-) diff --git a/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx b/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx index 64cc0c7394884..6e2b3ca694cd1 100644 --- a/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx +++ b/packages/grid/x-data-grid-pro/src/hooks/features/rowReorder/useGridRowReorder.tsx @@ -15,6 +15,7 @@ import { gridEditRowsStateSelector } from '@mui/x-data-grid/internals'; import { GridRowOrderChangeParams } from '../../../models/gridRowOrderChangeParams'; import { GridPrivateApiPro } from '../../../models/gridApiPro'; import { DataGridProProcessedProps } from '../../../models/dataGridProProps'; +import { GRID_REORDER_COL_DEF } from './gridRowReorderColDef'; type OwnerState = { classes: DataGridProProcessedProps['classes'] }; @@ -98,6 +99,7 @@ export const useGridRowReorder = ( }); originRowIndex.current = apiRef.current.getRowIndexRelativeToVisibleRows(params.id); + apiRef.current.setCellFocus(params.id, GRID_REORDER_COL_DEF.field); }, [isRowReorderDisabled, classes.rowDragging, logger, apiRef], ); diff --git a/packages/grid/x-data-grid/src/components/GridRow.tsx b/packages/grid/x-data-grid/src/components/GridRow.tsx index 85b9c308c4325..8268647f1b9ce 100644 --- a/packages/grid/x-data-grid/src/components/GridRow.tsx +++ b/packages/grid/x-data-grid/src/components/GridRow.tsx @@ -57,6 +57,8 @@ export interface GridRowProps extends React.HTMLAttributes { tabbableCell: string | null; row?: GridRowModel; isLastVisible?: boolean; + focusedCellColumnIndexNotInRange?: number; + isNotVisible?: boolean; onClick?: React.MouseEventHandler; onDoubleClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; @@ -114,6 +116,8 @@ const GridRow = React.forwardRef(function GridRow( firstColumnToRender, lastColumnToRender, isLastVisible = false, + focusedCellColumnIndexNotInRange, + isNotVisible, focusedCell, tabbableCell, onClick, @@ -277,6 +281,14 @@ const GridRow = React.forwardRef(function GridRow( Object.keys(editRowsState).length > 0); const editCellState = editRowsState[rowId]?.[column.field] ?? null; + let cellIsNotVisible = false; + + if ( + focusedCellColumnIndexNotInRange !== undefined && + visibleColumns[focusedCellColumnIndexNotInRange].field === column.field + ) { + cellIsNotVisible = true; + } return ( (function GridRow( colSpan={cellProps.colSpan} disableDragEvents={disableDragEvents} editCellState={editCellState} + isNotVisible={cellIsNotVisible} {...slotProps?.cell} /> ); @@ -322,27 +335,39 @@ const GridRow = React.forwardRef(function GridRow( } } - const style = { - ...styleProp, - maxHeight: rowHeight === 'auto' ? 'none' : rowHeight, // max-height doesn't support "auto" - minHeight, - }; + const style = React.useMemo(() => { + if (isNotVisible) { + return { + opacity: 0, + width: 0, + height: 0, + }; + } - if (sizes?.spacingTop) { - const property = rootProps.rowSpacingType === 'border' ? 'borderTopWidth' : 'marginTop'; - style[property] = sizes.spacingTop; - } + const rowStyle = { + ...styleProp, + maxHeight: rowHeight === 'auto' ? 'none' : rowHeight, // max-height doesn't support "auto" + minHeight, + }; - if (sizes?.spacingBottom) { - const property = rootProps.rowSpacingType === 'border' ? 'borderBottomWidth' : 'marginBottom'; - let propertyValue = style[property]; - // avoid overriding existing value - if (typeof propertyValue !== 'number') { - propertyValue = parseInt(propertyValue || '0', 10); + if (sizes?.spacingTop) { + const property = rootProps.rowSpacingType === 'border' ? 'borderTopWidth' : 'marginTop'; + rowStyle[property] = sizes.spacingTop; } - propertyValue += sizes.spacingBottom; - style[property] = propertyValue; - } + + if (sizes?.spacingBottom) { + const property = rootProps.rowSpacingType === 'border' ? 'borderBottomWidth' : 'marginBottom'; + let propertyValue = rowStyle[property]; + // avoid overriding existing value + if (typeof propertyValue !== 'number') { + propertyValue = parseInt(propertyValue || '0', 10); + } + propertyValue += sizes.spacingBottom; + rowStyle[property] = propertyValue; + } + + return rowStyle; + }, [isNotVisible, rowHeight, styleProp, minHeight, sizes, rootProps.rowSpacingType]); const rowClassNames = apiRef.current.unstable_applyPipeProcessors('rowClassName', [], rowId); @@ -370,7 +395,16 @@ const GridRow = React.forwardRef(function GridRow( for (let i = 0; i < renderedColumns.length; i += 1) { const column = renderedColumns[i]; - const indexRelativeToAllColumns = firstColumnToRender + i; + + let indexRelativeToAllColumns = firstColumnToRender + i; + + if (focusedCellColumnIndexNotInRange !== undefined && focusedCell) { + if (visibleColumns[focusedCellColumnIndexNotInRange].field === column.field) { + indexRelativeToAllColumns = focusedCellColumnIndexNotInRange; + } else { + indexRelativeToAllColumns -= 1; + } + } const cellColSpanInfo = apiRef.current.unstable_getCellColSpanInfo( rowId, @@ -386,6 +420,7 @@ const GridRow = React.forwardRef(function GridRow( showRightBorder: rootProps.showCellVerticalBorder, indexRelativeToAllColumns, }; + cells.push(getCell(column, cellProps)); } else { const { width } = cellColSpanInfo.cellProps; @@ -446,12 +481,14 @@ GridRow.propTypes = { * If `null`, no cell in this row has focus. */ focusedCell: PropTypes.string, + focusedCellColumnIndexNotInRange: PropTypes.number, /** * Index of the row in the whole sorted and filtered dataset. * If some rows above have expanded children, this index also take those children into account. */ index: PropTypes.number.isRequired, isLastVisible: PropTypes.bool, + isNotVisible: PropTypes.bool, lastColumnToRender: PropTypes.number.isRequired, onClick: PropTypes.func, onDoubleClick: PropTypes.func, diff --git a/packages/grid/x-data-grid/src/components/cell/GridCell.tsx b/packages/grid/x-data-grid/src/components/cell/GridCell.tsx index b465efbfaf72a..20ebcdf25dffb 100644 --- a/packages/grid/x-data-grid/src/components/cell/GridCell.tsx +++ b/packages/grid/x-data-grid/src/components/cell/GridCell.tsx @@ -18,6 +18,7 @@ import { GridCellModes, GridRowId, GridCellMode, + GridEditCellProps, } from '../../models'; import { GridRenderEditCellParams, @@ -26,7 +27,6 @@ import { } from '../../models/params/gridCellParams'; import { GridColDef, GridAlignment } from '../../models/colDef/gridColDef'; import { GridTreeNodeWithRender } from '../../models/gridRows'; -import { GridEditCellProps } from '../../models/gridEditRowModel'; import { useGridSelector, objectShallowCompare } from '../../hooks/utils/useGridSelector'; import { useGridApiContext } from '../../hooks/utils/useGridApiContext'; import { useGridRootProps } from '../../hooks/utils/useGridRootProps'; @@ -45,6 +45,7 @@ type GridCellV7Props = { width: number; colSpan?: number; disableDragEvents?: boolean; + isNotVisible?: boolean; editCellState: GridEditCellProps | null; onClick?: React.MouseEventHandler; onDoubleClick?: React.MouseEventHandler; @@ -267,6 +268,7 @@ const GridCell = React.forwardRef((props, ref) => row, colSpan, disableDragEvents, + isNotVisible, onClick, onDoubleClick, onMouseDown, @@ -330,12 +332,22 @@ const GridCell = React.forwardRef((props, ref) => [apiRef, field, rowId], ); - const style = { - minWidth: width, - maxWidth: width, - minHeight: height, - maxHeight: height === 'auto' ? 'none' : height, // max-height doesn't support "auto" - }; + const style = React.useMemo(() => { + if (isNotVisible) { + return { + padding: 0, + opacity: 0, + width: 0, + }; + } + const cellStyle = { + minWidth: width, + maxWidth: width, + minHeight: height, + maxHeight: height === 'auto' ? 'none' : height, // max-height doesn't support "auto" + }; + return cellStyle; + }, [width, height, isNotVisible]); React.useEffect(() => { if (!hasFocus || cellMode === GridCellModes.Edit) { @@ -393,6 +405,7 @@ const GridCell = React.forwardRef((props, ref) => let children: React.ReactNode = childrenProp; if (children === undefined) { const valueString = valueToRender?.toString(); + children = (
{valueString} @@ -483,6 +496,7 @@ GridCell.propTypes = { isValidating: PropTypes.bool, value: PropTypes.any, }), + isNotVisible: PropTypes.bool, height: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]), onClick: PropTypes.func, onDoubleClick: PropTypes.func, @@ -514,6 +528,7 @@ const GridCellV7 = React.forwardRef((props, ref row, colSpan, disableDragEvents, + isNotVisible, onClick, onDoubleClick, onMouseDown, @@ -637,12 +652,22 @@ const GridCellV7 = React.forwardRef((props, ref [apiRef, field, rowId], ); - const style = { - minWidth: width, - maxWidth: width, - minHeight: height, - maxHeight: height === 'auto' ? 'none' : height, // max-height doesn't support "auto" - }; + const style = React.useMemo(() => { + if (isNotVisible) { + return { + padding: 0, + opacity: 0, + width: 0, + }; + } + const cellStyle = { + minWidth: width, + maxWidth: width, + minHeight: height, + maxHeight: height === 'auto' ? 'none' : height, // max-height doesn't support "auto" + }; + return cellStyle; + }, [width, height, isNotVisible]); React.useEffect(() => { if (!hasFocus || cellMode === GridCellModes.Edit) { @@ -788,6 +813,7 @@ GridCellV7.propTypes = { value: PropTypes.any, }), height: PropTypes.oneOfType([PropTypes.oneOf(['auto']), PropTypes.number]).isRequired, + isNotVisible: PropTypes.bool, onClick: PropTypes.func, onDoubleClick: PropTypes.func, onDragEnter: PropTypes.func, diff --git a/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx b/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx index 02ee7006e6b9b..18264a0125d52 100644 --- a/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx +++ b/packages/grid/x-data-grid/src/hooks/features/virtualization/useGridVirtualScroller.tsx @@ -154,13 +154,53 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { const getRenderedColumnsRef = React.useRef( defaultMemoize( - (columns: GridStateColDef[], firstColumnToRender: number, lastColumnToRender: number) => { - return columns.slice(firstColumnToRender, lastColumnToRender); + ( + columns: GridStateColDef[], + firstColumnToRender: number, + lastColumnToRender: number, + minFirstColumn: number, + maxLastColumn: number, + indexOfColumnWithFocusedCell: number, + ) => { + // If the selected column is not within the current range of columns being displayed, + // we need to render it at either the left or right of the columns, + // depending on whether it is above or below the range. + let focusedCellColumnIndexNotInRange; + + const renderedColumns = columns.slice(firstColumnToRender, lastColumnToRender); + + if (indexOfColumnWithFocusedCell > -1) { + // check if it is not on the left pinned column. + if ( + firstColumnToRender > indexOfColumnWithFocusedCell && + indexOfColumnWithFocusedCell >= minFirstColumn + ) { + focusedCellColumnIndexNotInRange = indexOfColumnWithFocusedCell; + } + // check if it is not on the right pinned column. + else if ( + lastColumnToRender < indexOfColumnWithFocusedCell && + indexOfColumnWithFocusedCell < maxLastColumn + ) { + focusedCellColumnIndexNotInRange = indexOfColumnWithFocusedCell; + } + } + + return { + focusedCellColumnIndexNotInRange, + renderedColumns, + }; }, MEMOIZE_OPTIONS, ), ); + const indexOfColumnWithFocusedCell = React.useMemo(() => { + if (cellFocus !== null) { + return visibleColumns.findIndex((column) => column.field === cellFocus.field); + } + return -1; + }, [cellFocus, visibleColumns]); const getNearestIndexToRender = React.useCallback( (offset: number) => { const lastMeasuredIndexRelativeToAllRows = apiRef.current.getLastMeasuredRowIndex(); @@ -451,6 +491,13 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { apiRef.current.publishEvent('virtualScrollerTouchMove', {}, event); }); + const indexOfRowWithFocusedCell = React.useMemo(() => { + if (cellFocus !== null) { + return currentPage.rows.findIndex((row) => row.id === cellFocus.id); + } + return -1; + }, [cellFocus, currentPage.rows]); + const getRows = ( params: { renderContext: GridRenderContext | null; @@ -516,6 +563,32 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { }); } } + // If the selected row is not within the current range of rows being displayed, + // we need to render it at either the top or bottom of the rows, + // depending on whether it is above or below the range. + + let isRowWithFocusedCellNotInRange = false; + + if (indexOfRowWithFocusedCell > -1) { + const rowWithFocusedCell = currentPage.rows[indexOfRowWithFocusedCell]; + if ( + firstRowToRender > indexOfRowWithFocusedCell || + lastRowToRender < indexOfRowWithFocusedCell + ) { + isRowWithFocusedCellNotInRange = true; + if (indexOfRowWithFocusedCell > firstRowToRender) { + renderedRows.push(rowWithFocusedCell); + } else { + renderedRows.unshift(rowWithFocusedCell); + } + apiRef.current.calculateColSpan({ + rowId: rowWithFocusedCell.id, + minFirstColumn, + maxLastColumn, + columns: visibleColumns, + }); + } + } const [initialFirstColumnToRender, lastColumnToRender] = getRenderableIndexes({ firstIndex: nextRenderContext.firstColumnIndex, @@ -533,10 +606,21 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { visibleRows: currentPage.rows, }); - const renderedColumns = getRenderedColumnsRef.current( + let isColumnWihFocusedCellNotInRange = false; + if ( + firstColumnToRender > indexOfColumnWithFocusedCell || + lastColumnToRender < indexOfColumnWithFocusedCell + ) { + isColumnWihFocusedCellNotInRange = true; + } + + const { focusedCellColumnIndexNotInRange, renderedColumns } = getRenderedColumnsRef.current( visibleColumns, firstColumnToRender, lastColumnToRender, + minFirstColumn, + maxLastColumn, + isColumnWihFocusedCellNotInRange ? indexOfColumnWithFocusedCell : -1, ); const { style: rootRowStyle, ...rootRowProps } = rootProps.slotProps?.row || {}; @@ -552,7 +636,11 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { for (let i = 0; i < renderedRows.length; i += 1) { const { id, model } = renderedRows[i]; - const lastVisibleRowIndex = firstRowToRender + i === currentPage.rows.length - 1; + const isRowNotVisible = isRowWithFocusedCellNotInRange && cellFocus!.id === id; + + const lastVisibleRowIndex = isRowWithFocusedCellNotInRange + ? firstRowToRender + i === currentPage.rows.length + : firstRowToRender + i === currentPage.rows.length - 1; const baseRowHeight = !apiRef.current.rowHasAutoHeight(id) ? apiRef.current.unstable_getRowHeight(id) : 'auto'; @@ -569,6 +657,15 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { const focusedCell = cellFocus !== null && cellFocus.id === id ? cellFocus.field : null; + const columnWithFocusedCellNotInRange = + focusedCellColumnIndexNotInRange !== undefined && + visibleColumns[focusedCellColumnIndexNotInRange]; + + const renderedColumnsWithFocusedCell = + columnWithFocusedCellNotInRange && focusedCell + ? [columnWithFocusedCellNotInRange, ...renderedColumns] + : renderedColumns; + let tabbableCell: GridRowProps['tabbableCell'] = null; if (cellTabIndex !== null && cellTabIndex.id === id) { const cellParams = apiRef.current.getCellParams(id, cellTabIndex.field); @@ -591,10 +688,12 @@ export const useGridVirtualScroller = (props: UseGridVirtualScrollerProps) => { key={id} row={model} rowId={id} + focusedCellColumnIndexNotInRange={focusedCellColumnIndexNotInRange} + isNotVisible={isRowNotVisible} rowHeight={baseRowHeight} focusedCell={focusedCell} tabbableCell={tabbableCell} - renderedColumns={renderedColumns} + renderedColumns={renderedColumnsWithFocusedCell} visibleColumns={visibleColumns} firstColumnToRender={firstColumnToRender} lastColumnToRender={lastColumnToRender} diff --git a/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx b/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx index 6faa3c7270c84..2e30f56443748 100644 --- a/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx +++ b/packages/grid/x-data-grid/src/tests/cells.DataGrid.test.tsx @@ -1,9 +1,10 @@ import * as React from 'react'; import { spy } from 'sinon'; -import { createRenderer, userEvent } from '@mui/monorepo/test/utils'; +import { createRenderer, fireEvent, userEvent } from '@mui/monorepo/test/utils'; import { expect } from 'chai'; import { DataGrid } from '@mui/x-data-grid'; import { getCell } from 'test/utils/helperFn'; +import { getBasicGridData } from '@mui/x-data-grid-generator'; const isJSDOM = /jsdom/.test(window.navigator.userAgent); @@ -184,6 +185,37 @@ describe(' - Cells', () => { }).toWarnDev(['MUI: The cell with id=1 and field=brand received focus.']); }); + it('should keep the focused cell/row rendered in the DOM if it scrolls outside the viewport', function test() { + if (isJSDOM) { + this.skip(); + } + const rowHeight = 50; + const defaultData = getBasicGridData(20, 20); + + render( +
+ +
, + ); + + const virtualScroller = document.querySelector('.MuiDataGrid-virtualScroller')!; + + const cell = getCell(1, 3); + userEvent.mousePress(cell); + + const activeElementTextContent = document.activeElement?.textContent; + const columnWidth = document.activeElement!.clientWidth; + + const scrollTop = 10 * rowHeight; + fireEvent.scroll(virtualScroller, { target: { scrollTop } }); + expect(document.activeElement?.textContent).to.equal(activeElementTextContent); + + const scrollLeft = 10 * columnWidth; + fireEvent.scroll(virtualScroller, { target: { scrollLeft } }); + + expect(document.activeElement?.textContent).to.equal(activeElementTextContent); + }); + // See https://github.com/mui/mui-x/issues/6378 it('should not cause scroll jump when focused cell mounts in the render zone', async function test() { if (isJSDOM) {