From 7b3b16941a707baa1f0e1c44cf6016d63e0f9202 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Thu, 26 Feb 2026 20:37:28 +0000 Subject: [PATCH 1/4] DataGrid: split scroll/scroll to position/should focus position handling into hooks --- src/DataGrid.tsx | 93 +++++++---------------------- src/ScrollToCell.tsx | 42 ------------- src/hooks/index.ts | 3 + src/hooks/useGridDimensions.ts | 13 ++-- src/hooks/useScrollState.ts | 66 ++++++++++++++++++++ src/hooks/useScrollToPosition.tsx | 59 ++++++++++++++++++ src/hooks/useShouldFocusPosition.ts | 26 ++++++++ src/types.ts | 2 - src/utils/domUtils.ts | 26 ++++++++ 9 files changed, 211 insertions(+), 119 deletions(-) delete mode 100644 src/ScrollToCell.tsx create mode 100644 src/hooks/useScrollState.ts create mode 100644 src/hooks/useScrollToPosition.tsx create mode 100644 src/hooks/useShouldFocusPosition.ts diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index 71ee3dc72a..be2974cd35 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -1,4 +1,4 @@ -import { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react'; +import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'; import type { Key, KeyboardEvent } from 'react'; import { flushSync } from 'react-dom'; @@ -10,17 +10,22 @@ import { useColumnWidths, useGridDimensions, useLatestFunc, + useScrollState, + useScrollToPosition, + useShouldFocusPosition, useViewportColumns, useViewportRows, - type HeaderRowSelectionContextValue + type HeaderRowSelectionContextValue, + type PartialPosition } from './hooks'; import { - abs, assertIsValidKeyGetter, canExitGrid, classnames, createCellEvent, + focusCell, getCellStyle, + getCellToScroll, getColSpan, getLeftRightKey, getNextSelectedCellPosition, @@ -65,8 +70,6 @@ import EditCell from './EditCell'; import GroupedColumnHeaderRow from './GroupedColumnHeaderRow'; import HeaderRow from './HeaderRow'; import { defaultRenderRow } from './Row'; -import type { PartialPosition } from './ScrollToCell'; -import ScrollToCell from './ScrollToCell'; import { default as defaultRenderSortStatus } from './sortStatus'; import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/cell'; import { rootClassname, viewportDraggingClassname } from './style/core'; @@ -110,6 +113,7 @@ type SharedDivProps = Pick< | 'aria-rowcount' | 'className' | 'style' + | 'onScroll' >; export interface DataGridProps extends SharedDivProps { @@ -195,8 +199,6 @@ export interface DataGridProps extends Sha >; /** Function called whenever cell selection is changed */ onSelectedCellChange?: Maybe<(args: CellSelectArgs, NoInfer>) => void>; - /** Callback triggered when the grid is scrolled */ - onScroll?: Maybe<(event: React.UIEvent) => void>; /** Callback triggered when column is resized */ onColumnResize?: Maybe<(column: CalculatedColumn, width: number) => void>; /** Callback triggered when columns are reordered */ @@ -307,19 +309,22 @@ export function DataGrid(props: DataGridPr const enableVirtualization = rawEnableVirtualization ?? true; const direction = rawDirection ?? 'ltr'; + /** + * ref + */ + const gridRef = useRef(null); + /** * states */ - const [scrollTop, setScrollTop] = useState(0); - const [scrollLeft, setScrollLeft] = useState(0); + const { scrollTop, scrollLeft } = useScrollState(gridRef); + const [gridWidth, gridHeight] = useGridDimensions({ gridRef }); const [columnWidthsInternal, setColumnWidthsInternal] = useState( (): ColumnWidths => columnWidthsRaw ?? new Map() ); const [isColumnResizing, setIsColumnResizing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [draggedOverRowIdx, setDraggedOverRowIdx] = useState(undefined); - const [scrollToPosition, setScrollToPosition] = useState(null); - const [shouldFocusCell, setShouldFocusCell] = useState(false); const [previousRowIdx, setPreviousRowIdx] = useState(-1); const isColumnWidthsControlled = @@ -340,7 +345,6 @@ export function DataGrid(props: DataGridPr [columnWidths] ); - const [gridRef, gridWidth, gridHeight] = useGridDimensions(); const { columns, colSpanColumns, @@ -372,6 +376,8 @@ export function DataGrid(props: DataGridPr const [selectedPosition, setSelectedPosition] = useState( (): SelectCellState | EditCellState => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }) ); + const { setScrollToPosition, scrollToElement } = useScrollToPosition({ gridRef }); + const { shouldFocusPositionRef } = useShouldFocusPosition({ gridRef, selectedPosition }); /** * computed values @@ -480,19 +486,8 @@ export function DataGrid(props: DataGridPr const selectHeaderCellLatest = useLatestFunc(selectHeaderCell); /** - * effects + * Misc hooks */ - useLayoutEffect(() => { - if (shouldFocusCell) { - if (selectedPosition.idx === -1) { - focusRow(gridRef.current!); - } else { - focusCell(gridRef.current!); - } - setShouldFocusCell(false); - } - }, [shouldFocusCell, selectedPosition.idx, gridRef]); - useImperativeHandle( ref, (): DataGridHandle => ({ @@ -621,16 +616,6 @@ export function DataGrid(props: DataGridPr } } - function handleScroll(event: React.UIEvent) { - const { scrollTop, scrollLeft } = event.currentTarget; - flushSync(() => { - setScrollTop(scrollTop); - // scrollLeft is nagative when direction is rtl - setScrollLeft(abs(scrollLeft)); - }); - onScroll?.(event); - } - function updateRow(column: CalculatedColumn, rowIdx: number, row: R) { if (typeof onRowsChange !== 'function') return; if (row === rows[rowIdx]) return; @@ -814,7 +799,7 @@ export function DataGrid(props: DataGridPr // Avoid re-renders if the selected cell state is the same scrollIntoView(getCellToScroll(gridRef.current!)); } else { - setShouldFocusCell(options?.shouldFocusCell === true); + shouldFocusPositionRef.current = options?.shouldFocusCell === true; setSelectedPosition({ ...position, mode: 'SELECT' }); } @@ -1004,7 +989,7 @@ export function DataGrid(props: DataGridPr const closeOnExternalRowChange = column.editorOptions?.closeOnExternalRowChange ?? true; const closeEditor = (shouldFocusCell: boolean) => { - setShouldFocusCell(shouldFocusCell); + shouldFocusPositionRef.current = shouldFocusCell; setSelectedPosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'SELECT' })); }; @@ -1191,7 +1176,7 @@ export function DataGrid(props: DataGridPr }} dir={direction} ref={gridRef} - onScroll={handleScroll} + onScroll={onScroll} onKeyDown={handleKeyDown} onCopy={handleCellCopy} onPaste={handleCellPaste} @@ -1303,43 +1288,11 @@ export function DataGrid(props: DataGridPr {/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */} {renderMeasuringCells(viewportColumns)} - {scrollToPosition !== null && ( - - )} + {scrollToElement} ); } -function getRowToScroll(gridEl: HTMLDivElement) { - return gridEl.querySelector(':scope > [role="row"][tabindex="0"]'); -} - -function getCellToScroll(gridEl: HTMLDivElement) { - return gridEl.querySelector(':scope > [role="row"] > [tabindex="0"]'); -} - function isSamePosition(p1: Position, p2: Position) { return p1.idx === p2.idx && p1.rowIdx === p2.rowIdx; } - -function focusElement(element: HTMLDivElement | null, shouldScroll: boolean) { - if (element === null) return; - - if (shouldScroll) { - scrollIntoView(element); - } - - element.focus({ preventScroll: true }); -} - -function focusRow(gridEl: HTMLDivElement) { - focusElement(getRowToScroll(gridEl), true); -} - -function focusCell(gridEl: HTMLDivElement, shouldScroll = true) { - focusElement(getCellToScroll(gridEl), shouldScroll); -} diff --git a/src/ScrollToCell.tsx b/src/ScrollToCell.tsx deleted file mode 100644 index 8b3ebda3c4..0000000000 --- a/src/ScrollToCell.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useLayoutEffect, useRef } from 'react'; - -import { scrollIntoView } from './utils'; - -export interface PartialPosition { - readonly idx?: number | undefined; - readonly rowIdx?: number | undefined; -} - -export default function ScrollToCell({ - scrollToPosition: { idx, rowIdx }, - gridRef, - setScrollToCellPosition -}: { - scrollToPosition: PartialPosition; - gridRef: React.RefObject; - setScrollToCellPosition: (cell: null) => void; -}) { - const ref = useRef(null); - - useLayoutEffect(() => { - const grid = gridRef.current!; - const { scrollLeft, scrollTop } = grid; - // scroll until the cell is completely visible - // this is needed if the grid has auto-sized columns - // setting the behavior to auto so it can be overridden - scrollIntoView(ref.current, 'auto'); - if (grid.scrollLeft === scrollLeft && grid.scrollTop === scrollTop) { - setScrollToCellPosition(null); - } - }); - - return ( -
- ); -} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 709f729be0..5e0d4577a0 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,5 +4,8 @@ export * from './useGridDimensions'; export * from './useLatestFunc'; export * from './useRovingTabIndex'; export * from './useRowSelection'; +export * from './useScrollState'; +export * from './useScrollToPosition'; +export * from './useShouldFocusPosition'; export * from './useViewportColumns'; export * from './useViewportRows'; diff --git a/src/hooks/useGridDimensions.ts b/src/hooks/useGridDimensions.ts index 907d6f02e3..1dbfb10498 100644 --- a/src/hooks/useGridDimensions.ts +++ b/src/hooks/useGridDimensions.ts @@ -1,8 +1,11 @@ -import { useLayoutEffect, useRef, useState } from 'react'; +import { useLayoutEffect, useState } from 'react'; import { flushSync } from 'react-dom'; -export function useGridDimensions() { - const gridRef = useRef(null); +export function useGridDimensions({ + gridRef +}: { + gridRef: React.RefObject; +}) { const [inlineSize, setInlineSize] = useState(1); const [blockSize, setBlockSize] = useState(1); @@ -32,7 +35,7 @@ export function useGridDimensions() { return () => { resizeObserver.disconnect(); }; - }, []); + }, [gridRef]); - return [gridRef, inlineSize, blockSize] as const; + return [inlineSize, blockSize] as const; } diff --git a/src/hooks/useScrollState.ts b/src/hooks/useScrollState.ts new file mode 100644 index 0000000000..8bb48a4f10 --- /dev/null +++ b/src/hooks/useScrollState.ts @@ -0,0 +1,66 @@ +import { useCallback, useSyncExternalStore } from 'react'; + +import { abs } from '../utils'; + +interface ScrollState { + readonly scrollTop: number; + readonly scrollLeft: number; +} + +const initialScrollState: ScrollState = { + scrollTop: 0, + scrollLeft: 0 +}; + +function getServerSnapshot() { + return initialScrollState; +} + +const scrollStateMap = new WeakMap, ScrollState>(); + +export function useScrollState(gridRef: React.RefObject): ScrollState { + const subscribe = useCallback( + (onStoreChange: () => void) => { + if (gridRef.current === null) return () => {}; + + const el = gridRef.current; + + // prime the scroll state map with the initial values + setScrollState(); + + function setScrollState() { + const { scrollTop } = el; + // scrollLeft is negative when direction is rtl + const scrollLeft = abs(el.scrollLeft); + + const prev = scrollStateMap.get(gridRef) ?? initialScrollState; + if (prev.scrollTop === scrollTop && prev.scrollLeft === scrollLeft) { + return false; + } + + scrollStateMap.set(gridRef, { scrollTop, scrollLeft }); + return true; + } + + function onScroll() { + if (setScrollState()) { + onStoreChange(); + } + } + + el.addEventListener('scroll', onScroll); + + return () => el.removeEventListener('scroll', onScroll); + }, + [gridRef] + ); + + const getSnapshot = useCallback((): ScrollState => { + // gridRef.current is null during initial render, suspending, or + // to avoid returning a different state in those cases, + // we key the ref object instead of the element itself + return scrollStateMap.get(gridRef) ?? initialScrollState; + }, [gridRef]); + + return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/src/hooks/useScrollToPosition.tsx b/src/hooks/useScrollToPosition.tsx new file mode 100644 index 0000000000..ee356c20e8 --- /dev/null +++ b/src/hooks/useScrollToPosition.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react'; + +import { scrollIntoView } from '../utils'; + +export interface PartialPosition { + readonly idx?: number | undefined; + readonly rowIdx?: number | undefined; +} + +interface Props { + gridRef: React.RefObject; +} + +export function useScrollToPosition({ gridRef }: Props) { + const [scrollToPosition, setScrollToPosition] = useState(null); + + return { + setScrollToPosition, + scrollToElement: scrollToPosition && ( + + ) + } as const; +} + +interface ScrollToCellProps extends Props { + scrollToPosition: PartialPosition; + setScrollToCellPosition: (cell: null) => void; +} + +function ScrollToCell({ + gridRef, + scrollToPosition: { idx, rowIdx }, + setScrollToCellPosition +}: ScrollToCellProps) { + return ( +
{ + if (div === null) return; + const grid = gridRef.current!; + const { scrollLeft, scrollTop } = grid; + // scroll until the cell is completely visible + // this is needed if the grid has auto-sized columns + // setting the behavior to auto so it can be overridden + scrollIntoView(div, 'auto'); + if (grid.scrollLeft === scrollLeft && grid.scrollTop === scrollTop) { + setScrollToCellPosition(null); + } + }} + style={{ + gridColumn: idx === undefined ? '1/-1' : idx + 1, + gridRow: rowIdx === undefined ? '1/-1' : rowIdx + 1 + }} + /> + ); +} diff --git a/src/hooks/useShouldFocusPosition.ts b/src/hooks/useShouldFocusPosition.ts new file mode 100644 index 0000000000..a7c9b81da0 --- /dev/null +++ b/src/hooks/useShouldFocusPosition.ts @@ -0,0 +1,26 @@ +import { useLayoutEffect, useRef } from 'react'; + +import { focusCell, focusRow } from '../utils'; + +export function useShouldFocusPosition({ + gridRef, + selectedPosition +}: { + gridRef: React.RefObject; + selectedPosition: { idx: number; rowIdx: number }; +}) { + const shouldFocusPositionRef = useRef(false); + + useLayoutEffect(() => { + if (shouldFocusPositionRef.current) { + if (selectedPosition.idx === -1) { + focusRow(gridRef.current!); + } else { + focusCell(gridRef.current!); + } + shouldFocusPositionRef.current = false; + } + }, [selectedPosition, gridRef]); + + return { shouldFocusPositionRef } as const; +} diff --git a/src/types.ts b/src/types.ts index 3eb61268aa..ff01d72d01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,8 +6,6 @@ export type Omit = Pick>; export type Maybe = T | undefined | null; -export type StateSetter = React.Dispatch>; - export interface Column { /** The name of the column. Displayed in the header cell by default */ readonly name: string | ReactElement; diff --git a/src/utils/domUtils.ts b/src/utils/domUtils.ts index 5c3378d81c..1803bb96ee 100644 --- a/src/utils/domUtils.ts +++ b/src/utils/domUtils.ts @@ -7,3 +7,29 @@ export function stopPropagation(event: React.SyntheticEvent) { export function scrollIntoView(element: Maybe, behavior: ScrollBehavior = 'instant') { element?.scrollIntoView({ inline: 'nearest', block: 'nearest', behavior }); } + +function getRowToScroll(gridEl: HTMLDivElement) { + return gridEl.querySelector(':scope > [role="row"][tabindex="0"]'); +} + +export function getCellToScroll(gridEl: HTMLDivElement) { + return gridEl.querySelector(':scope > [role="row"] > [tabindex="0"]'); +} + +function focusElement(element: HTMLDivElement | null, shouldScroll: boolean) { + if (element === null) return; + + if (shouldScroll) { + scrollIntoView(element); + } + + element.focus({ preventScroll: true }); +} + +export function focusRow(gridEl: HTMLDivElement) { + focusElement(getRowToScroll(gridEl), true); +} + +export function focusCell(gridEl: HTMLDivElement, shouldScroll = true) { + focusElement(getCellToScroll(gridEl), shouldScroll); +} From 00adf056cb2b5db9f46960c3e3b20df1cace0717 Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Thu, 26 Feb 2026 20:42:14 +0000 Subject: [PATCH 2/4] rename --- src/DataGrid.tsx | 4 ++-- src/hooks/useScrollToPosition.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/DataGrid.tsx b/src/DataGrid.tsx index be2974cd35..7f406fa33c 100644 --- a/src/DataGrid.tsx +++ b/src/DataGrid.tsx @@ -376,7 +376,7 @@ export function DataGrid(props: DataGridPr const [selectedPosition, setSelectedPosition] = useState( (): SelectCellState | EditCellState => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' }) ); - const { setScrollToPosition, scrollToElement } = useScrollToPosition({ gridRef }); + const { setScrollToPosition, scrollToPositionElement } = useScrollToPosition({ gridRef }); const { shouldFocusPositionRef } = useShouldFocusPosition({ gridRef, selectedPosition }); /** @@ -1288,7 +1288,7 @@ export function DataGrid(props: DataGridPr {/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */} {renderMeasuringCells(viewportColumns)} - {scrollToElement} + {scrollToPositionElement}
); } diff --git a/src/hooks/useScrollToPosition.tsx b/src/hooks/useScrollToPosition.tsx index ee356c20e8..a7e47404f3 100644 --- a/src/hooks/useScrollToPosition.tsx +++ b/src/hooks/useScrollToPosition.tsx @@ -16,7 +16,7 @@ export function useScrollToPosition({ gridRef }: Props) { return { setScrollToPosition, - scrollToElement: scrollToPosition && ( + scrollToPositionElement: scrollToPosition && ( Date: Thu, 26 Feb 2026 20:52:27 +0000 Subject: [PATCH 3/4] update onScroll prop doc --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2940542996..d2a571bbe1 100644 --- a/README.md +++ b/README.md @@ -627,9 +627,9 @@ Arguments: - `args.row`: `R | undefined` - row object of the currently selected cell - `args.column`: `CalculatedColumn` - column object of the currently selected cell -###### `onScroll?: Maybe<(event: React.UIEvent) => void>` +###### `onScroll?: React.UIEventHandler | undefined` -Callback triggered when the grid is scrolled. +Native DOM `onScroll` prop. ###### `onColumnResize?: Maybe<(column: CalculatedColumn, width: number) => void>` From 140c71480e0fc2fff3364e0e5da44119edc9255a Mon Sep 17 00:00:00 2001 From: Nicolas Stepien Date: Fri, 27 Feb 2026 14:45:55 +0000 Subject: [PATCH 4/4] no hook, so we can inline the element --- src/hooks/useScrollToPosition.tsx | 53 ++++++++++--------------------- 1 file changed, 17 insertions(+), 36 deletions(-) diff --git a/src/hooks/useScrollToPosition.tsx b/src/hooks/useScrollToPosition.tsx index a7e47404f3..84c18221a3 100644 --- a/src/hooks/useScrollToPosition.tsx +++ b/src/hooks/useScrollToPosition.tsx @@ -17,43 +17,24 @@ export function useScrollToPosition({ gridRef }: Props) { return { setScrollToPosition, scrollToPositionElement: scrollToPosition && ( - { + if (div === null) return; + const grid = gridRef.current!; + const { scrollLeft, scrollTop } = grid; + // scroll until the cell/column is completely visible + // this is needed if the grid has auto-sized columns + // setting the behavior to auto so it can be overridden + scrollIntoView(div, 'auto'); + if (grid.scrollLeft === scrollLeft && grid.scrollTop === scrollTop) { + setScrollToPosition(null); + } + }} + style={{ + gridColumn: scrollToPosition.idx === undefined ? '1/-1' : scrollToPosition.idx + 1, + gridRow: scrollToPosition.rowIdx === undefined ? '1/-1' : scrollToPosition.rowIdx + 1 + }} /> ) } as const; } - -interface ScrollToCellProps extends Props { - scrollToPosition: PartialPosition; - setScrollToCellPosition: (cell: null) => void; -} - -function ScrollToCell({ - gridRef, - scrollToPosition: { idx, rowIdx }, - setScrollToCellPosition -}: ScrollToCellProps) { - return ( -
{ - if (div === null) return; - const grid = gridRef.current!; - const { scrollLeft, scrollTop } = grid; - // scroll until the cell is completely visible - // this is needed if the grid has auto-sized columns - // setting the behavior to auto so it can be overridden - scrollIntoView(div, 'auto'); - if (grid.scrollLeft === scrollLeft && grid.scrollTop === scrollTop) { - setScrollToCellPosition(null); - } - }} - style={{ - gridColumn: idx === undefined ? '1/-1' : idx + 1, - gridRow: rowIdx === undefined ? '1/-1' : rowIdx + 1 - }} - /> - ); -}