diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index a3c977985b..536fdc43e1 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -6,6 +6,7 @@ import { HeaderRowSelectionChangeContext, HeaderRowSelectionContext, RowSelectionChangeContext, + useActivePosition, useCalculatedColumns, useColumnWidths, useGridDimensions, @@ -77,16 +78,6 @@ import { } from './style/core'; import SummaryRow from './SummaryRow'; -interface ActiveCellState extends Position { - readonly mode: 'ACTIVE'; -} - -interface EditCellState extends Position { - readonly mode: 'EDIT'; - readonly row: R; - readonly originalRow: R; -} - export type DefaultColumnOptions = Pick< Column, | 'renderCell' @@ -365,33 +356,50 @@ export function DataGrid(props: DataGridPr enableVirtualization }); + /** + * computed values + */ + const isTreeGrid = role === 'treegrid'; const topSummaryRowsCount = topSummaryRows?.length ?? 0; const bottomSummaryRowsCount = bottomSummaryRows?.length ?? 0; const summaryRowsCount = topSummaryRowsCount + bottomSummaryRowsCount; const headerAndTopSummaryRowsCount = headerRowsCount + topSummaryRowsCount; const groupedColumnHeaderRowsCount = headerRowsCount - 1; const minRowIdx = -headerAndTopSummaryRowsCount; - const mainHeaderRowIdx = minRowIdx + groupedColumnHeaderRowsCount; const maxRowIdx = rows.length + bottomSummaryRowsCount - 1; - const frozenShadowStyles: React.CSSProperties = { - gridColumnStart: lastFrozenColumnIndex + 2, - insetInlineStart: totalFrozenColumnWidth - }; - - const [activePosition, setActivePosition] = useState>( - getInitialActivePosition - ); - - /** - * computed values - */ - const isTreeGrid = role === 'treegrid'; + const mainHeaderRowIdx = minRowIdx + groupedColumnHeaderRowsCount; + const maxColIdx = columns.length - 1; const headerRowsHeight = headerRowsCount * headerRowHeight; const summaryRowsHeight = summaryRowsCount * summaryRowHeight; const clientHeight = gridHeight - headerRowsHeight - summaryRowsHeight; const isSelectable = selectedRows != null && onSelectedRowsChange != null; const { leftKey, rightKey } = getLeftRightKey(direction); const ariaRowCount = rawAriaRowCount ?? headerRowsCount + rows.length + summaryRowsCount; + const frozenShadowStyles: React.CSSProperties = { + gridColumnStart: lastFrozenColumnIndex + 2, + insetInlineStart: totalFrozenColumnWidth + }; + + const { + activePosition, + setActivePosition, + activePositionIsInActiveBounds, + activePositionIsInViewport, + activePositionIsRow, + activePositionIsCellInViewport, + validatePosition, + getActiveColumn, + getActiveRow + } = useActivePosition({ + columns, + rows, + isTreeGrid, + maxColIdx, + minRowIdx, + maxRowIdx, + setDraggedOverRowIdx, + setShouldFocusPosition + }); const defaultGridComponents = useMemo( () => ({ @@ -441,14 +449,6 @@ export function DataGrid(props: DataGridPr enableVirtualization }); - const maxColIdx = columns.length - 1; - const { - isPositionInActiveBounds: activePositionIsInActiveBounds, - isPositionInViewport: activePositionIsInViewport, - isRowInActiveBounds: activePositionIsRow, - isCellInViewport: activePositionIsCellInViewport - } = validatePosition(activePosition); - const { viewportColumns, iterateOverViewportColumnsForRow, @@ -656,29 +656,32 @@ export function DataGrid(props: DataGridPr function commitEditorChanges() { if (activePosition.mode !== 'EDIT') return; - updateRow(columns[activePosition.idx], activePosition.rowIdx, activePosition.row); + updateRow(getActiveColumn(), activePosition.rowIdx, activePosition.row); } function handleCellCopy(event: CellClipboardEvent) { if (!activePositionIsCellInViewport) return; - const { idx, rowIdx } = activePosition; - onCellCopy?.({ row: rows[rowIdx], column: columns[idx] }, event); + onCellCopy?.({ row: getActiveRow(), column: getActiveColumn() }, event); } function handleCellPaste(event: CellClipboardEvent) { - if (!onCellPaste || !onRowsChange || !isCellEditable(activePosition)) { + if ( + typeof onCellPaste !== 'function' || + typeof onRowsChange !== 'function' || + !isCellEditable(activePosition) + ) { return; } - const { idx, rowIdx } = activePosition; - const column = columns[idx]; - const updatedRow = onCellPaste({ row: rows[rowIdx], column }, event); - updateRow(column, rowIdx, updatedRow); + const column = getActiveColumn(); + const row = getActiveRow(); + const updatedRow = onCellPaste({ row, column }, event); + updateRow(column, activePosition.rowIdx, updatedRow); } function handleCellInput(event: KeyboardEvent) { if (!activePositionIsCellInViewport) return; - const row = rows[activePosition.rowIdx]; + const row = getActiveRow(); const { key, shiftKey } = event; // Select the row on Shift + Space @@ -764,9 +767,9 @@ export function DataGrid(props: DataGridPr function updateRows(startRowIdx: number, endRowIdx: number) { if (onRowsChange == null) return; - const { rowIdx, idx } = activePosition; - const column = columns[idx]; - const sourceRow = rows[rowIdx]; + const { idx } = activePosition; + const column = getActiveColumn(); + const sourceRow = getActiveRow(); const updatedRows = [...rows]; const indexes: number[] = []; for (let i = startRowIdx; i < endRowIdx; i++) { @@ -784,50 +787,6 @@ export function DataGrid(props: DataGridPr } } - /** - * utils - */ - function getInitialActivePosition(): ActiveCellState { - return { idx: -1, rowIdx: minRowIdx - 1, mode: 'ACTIVE' }; - } - - /** - * Returns whether the given position represents a valid cell or row position in the grid. - * Active bounds: any valid position in the grid - * Viewport: any valid position in the grid outside of header rows and summary rows - * Row selection is only allowed in TreeDataGrid - */ - function validatePosition({ idx, rowIdx }: Position) { - // check column position - const isColumnPositionAllColumns = isTreeGrid && idx === -1; - const isColumnPositionInActiveBounds = idx >= 0 && idx <= maxColIdx; - - // check row position - const isRowPositionInActiveBounds = rowIdx >= minRowIdx && rowIdx <= maxRowIdx; - const isRowPositionInViewport = rowIdx >= 0 && rowIdx < rows.length; - - // row status - const isRowInActiveBounds = isColumnPositionAllColumns && isRowPositionInActiveBounds; - const isRowInViewport = isColumnPositionAllColumns && isRowPositionInViewport; - - // cell status - const isCellInActiveBounds = isColumnPositionInActiveBounds && isRowPositionInActiveBounds; - const isCellInViewport = isColumnPositionInActiveBounds && isRowPositionInViewport; - - // position status - const isPositionInActiveBounds = isRowInActiveBounds || isCellInActiveBounds; - const isPositionInViewport = isRowInViewport || isCellInViewport; - - return { - isPositionInActiveBounds, - isPositionInViewport, - isRowInActiveBounds, - isRowInViewport, - isCellInActiveBounds, - isCellInViewport - }; - } - function isCellEditable(position: Position): boolean { return ( validatePosition(position).isCellInViewport && @@ -977,19 +936,19 @@ export function DataGrid(props: DataGridPr } function getDragHandle() { - if (onFill == null || activePosition.mode === 'EDIT' || !activePositionIsCellInViewport) { + if (onFill == null || activePosition.mode !== 'ACTIVE' || !activePositionIsCellInViewport) { return; } - const { idx, rowIdx } = activePosition; - const column = columns[idx]; + const { rowIdx } = activePosition; + const column = getActiveColumn(); if (column.renderEditCell == null || column.editable === false) { return; } const isLastRow = rowIdx === maxRowIdx; const columnWidth = getColumnWidth(column); - const colSpan = column.colSpan?.({ type: 'ROW', row: rows[rowIdx] }) ?? 1; + const colSpan = column.colSpan?.({ type: 'ROW', row: getActiveRow() }) ?? 1; const { insetInlineStart, ...style } = getCellStyle(column, colSpan); const marginEnd = 'calc(var(--rdg-drag-handle-size) * -0.5 + 1px)'; const isLastColumn = column.idx + colSpan - 1 === maxColIdx; @@ -1023,22 +982,21 @@ export function DataGrid(props: DataGridPr if ( !activePositionIsCellInViewport || activePosition.rowIdx !== rowIdx || - activePosition.mode === 'ACTIVE' + activePosition.mode !== 'EDIT' ) { return; } - const { idx, row } = activePosition; - const column = columns[idx]; + const { row } = activePosition; + const column = getActiveColumn(); const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row }); - const closeOnExternalRowChange = column.editorOptions?.closeOnExternalRowChange ?? true; - const closeEditor = (shouldFocus: boolean) => { + function closeEditor(shouldFocus: boolean) { setShouldFocusPosition(shouldFocus); setActivePosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'ACTIVE' })); - }; + } - const onRowChange = (row: R, commitChanges: boolean, shouldFocus: boolean) => { + function onRowChange(row: R, commitChanges: boolean, shouldFocus: boolean) { if (commitChanges) { // Prevents two issues when editor is closed by clicking on a different cell // @@ -1051,11 +1009,6 @@ export function DataGrid(props: DataGridPr } else { setActivePosition((position) => ({ ...position, row })); } - }; - - if (closeOnExternalRowChange && rows[activePosition.rowIdx] !== activePosition.originalRow) { - // Discard changes if rows are updated from outside - closeEditor(false); } return ( @@ -1135,12 +1088,6 @@ export function DataGrid(props: DataGridPr .toArray(); } - // Reset the positions if the current values are no longer valid. This can happen if a column or row is removed - if (activePosition.idx > maxColIdx || activePosition.rowIdx > maxRowIdx) { - setActivePosition(getInitialActivePosition()); - setDraggedOverRowIdx(undefined); - } - // Keep the state and prop in sync if (isColumnWidthsControlled && columnWidthsInternal !== columnWidthsRaw) { setColumnWidthsInternal(columnWidthsRaw); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 709f729be0..02d9904633 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useActivePosition'; export * from './useCalculatedColumns'; export * from './useColumnWidths'; export * from './useGridDimensions'; diff --git a/src/hooks/useActivePosition.ts b/src/hooks/useActivePosition.ts new file mode 100644 index 0000000000..c1ee8cbc82 --- /dev/null +++ b/src/hooks/useActivePosition.ts @@ -0,0 +1,142 @@ +import { useState } from 'react'; + +import type { CalculatedColumn, Position, StateSetter } from '../types'; + +interface ActivePosition extends Position { + readonly mode: 'ACTIVE'; +} + +interface EditPosition extends Position { + readonly mode: 'EDIT'; + readonly row: R; + readonly originalRow: R; +} + +const initialActivePosition: ActivePosition = { + idx: -1, + // use -Infinity to avoid issues when adding header rows or top summary rows + rowIdx: Number.NEGATIVE_INFINITY, + mode: 'ACTIVE' +}; + +export function useActivePosition({ + columns, + rows, + isTreeGrid, + maxColIdx, + minRowIdx, + maxRowIdx, + setDraggedOverRowIdx, + setShouldFocusPosition +}: { + columns: readonly CalculatedColumn[]; + rows: readonly R[]; + isTreeGrid: boolean; + maxColIdx: number; + minRowIdx: number; + maxRowIdx: number; + setDraggedOverRowIdx: StateSetter; + setShouldFocusPosition: StateSetter; +}) { + const [activePosition, setActivePosition] = useState>( + initialActivePosition + ); + + /** + * Returns whether the given position represents a valid cell or row position in the grid. + * Active bounds: any valid position in the grid + * Viewport: any valid position in the grid outside of header rows and summary rows + * Row selection is only allowed in TreeDataGrid + */ + function validatePosition({ idx, rowIdx }: Position) { + // check column position + const isColumnPositionAllColumns = isTreeGrid && idx === -1; + const isColumnPositionInActiveBounds = idx >= 0 && idx <= maxColIdx; + + // check row position + const isRowPositionInActiveBounds = rowIdx >= minRowIdx && rowIdx <= maxRowIdx; + const isRowPositionInViewport = rowIdx >= 0 && rowIdx < rows.length; + + // row status + const isRowInActiveBounds = isColumnPositionAllColumns && isRowPositionInActiveBounds; + const isRowInViewport = isColumnPositionAllColumns && isRowPositionInViewport; + + // cell status + const isCellInActiveBounds = isColumnPositionInActiveBounds && isRowPositionInActiveBounds; + const isCellInViewport = isColumnPositionInActiveBounds && isRowPositionInViewport; + + // position status + const isPositionInActiveBounds = isRowInActiveBounds || isCellInActiveBounds; + const isPositionInViewport = isRowInViewport || isCellInViewport; + + return { + isPositionInActiveBounds, + isPositionInViewport, + isRowInActiveBounds, + isRowInViewport, + isCellInActiveBounds, + isCellInViewport + }; + } + + function getResolvedValues(position: ActivePosition | EditPosition) { + return { + resolvedActivePosition: position, + validatedPosition: validatePosition(position) + }; + } + + function getActiveColumn() { + if (!validatedPosition.isCellInActiveBounds) { + throw new Error('No column for active position'); + } + return columns[resolvedActivePosition.idx]; + } + + function getActiveRow() { + if (!validatedPosition.isPositionInViewport) { + throw new Error('No row for active position'); + } + return rows[resolvedActivePosition.rowIdx]; + } + + let { resolvedActivePosition, validatedPosition } = getResolvedValues(activePosition); + + // Reinitialize the active position and immediately use the new state if it is no longer valid. + // This can happen when a column or row is removed. + if ( + !validatedPosition.isPositionInActiveBounds && + resolvedActivePosition !== initialActivePosition + ) { + setActivePosition(initialActivePosition); + setDraggedOverRowIdx(undefined); + ({ resolvedActivePosition, validatedPosition } = getResolvedValues(initialActivePosition)); + } else if (resolvedActivePosition.mode === 'EDIT') { + const closeOnExternalRowChange = + getActiveColumn().editorOptions?.closeOnExternalRowChange ?? true; + + // Discard changes if the row is updated from outside + if (closeOnExternalRowChange && getActiveRow() !== resolvedActivePosition.originalRow) { + const newPosition: ActivePosition = { + idx: resolvedActivePosition.idx, + rowIdx: resolvedActivePosition.rowIdx, + mode: 'ACTIVE' + }; + setActivePosition(newPosition); + setShouldFocusPosition(false); + ({ resolvedActivePosition, validatedPosition } = getResolvedValues(newPosition)); + } + } + + return { + activePosition: resolvedActivePosition, + setActivePosition, + activePositionIsInActiveBounds: validatedPosition.isPositionInActiveBounds, + activePositionIsInViewport: validatedPosition.isPositionInViewport, + activePositionIsRow: validatedPosition.isRowInActiveBounds, + activePositionIsCellInViewport: validatedPosition.isCellInViewport, + validatePosition, + getActiveColumn, + getActiveRow + } as const; +} diff --git a/test/browser/TreeDataGrid.test.tsx b/test/browser/TreeDataGrid.test.tsx index 8588f7c0c6..676548e969 100644 --- a/test/browser/TreeDataGrid.test.tsx +++ b/test/browser/TreeDataGrid.test.tsx @@ -465,3 +465,42 @@ test('custom renderGroupCell', async () => { await expect.element(getRowWithCell(usaCell).getCell().nth(4)).toHaveTextContent('1'); await expect.element(getRowWithCell(canadaCell).getCell().nth(4)).toHaveTextContent('3'); }); + +test('adding a top summary row when no rows or cells are active should not focus the summary row', async () => { + const rows: readonly Row[] = []; + + function Test() { + const [topSummaryRows, setTopSummaryRows] = useState((): readonly SummaryRow[] => []); + + return ( + <> + + ({})} + expandedGroupIds={new Set()} + onExpandedGroupIdsChange={() => {}} + /> + + ); + } + + await page.render(); + const addSummaryRowButton = page.getByRole('button', { name: 'Add summary row' }); + const activeRow = page.getBySelector(`.${rowActiveClassname}`); + + await expect.element(activeCell).not.toBeInTheDocument(); + await expect.element(activeRow).not.toBeInTheDocument(); + + await userEvent.click(addSummaryRowButton); + await expect.element(activeCell).not.toBeInTheDocument(); + await expect.element(activeRow).not.toBeInTheDocument(); +});