-
Notifications
You must be signed in to change notification settings - Fork 2.2k
DataGrid: split scroll/scroll to position/should focus position handling into hooks #3986
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 6 commits
7b3b169
00adf05
745f209
140c714
75f1a4a
8c44657
849d069
f708783
308178e
bbd3460
f202ca7
1f4cbff
2e42da3
fe54c01
e4c59ae
c3a1990
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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, | ||
| getNextActivePosition, | ||
|
|
@@ -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 { | ||
|
|
@@ -115,6 +118,7 @@ type SharedDivProps = Pick< | |
| | 'aria-rowcount' | ||
| | 'className' | ||
| | 'style' | ||
| | 'onScroll' | ||
| >; | ||
|
|
||
| export interface DataGridProps<R, SR = unknown, K extends Key = Key> extends SharedDivProps { | ||
|
|
@@ -199,8 +203,6 @@ export interface DataGridProps<R, SR = unknown, K extends Key = Key> extends Sha | |
| >; | ||
| /** Function called whenever the active position is changed */ | ||
| onActivePositionChange?: Maybe<(args: PositionChangeArgs<NoInfer<R>, NoInfer<SR>>) => void>; | ||
| /** Callback triggered when the grid is scrolled */ | ||
| onScroll?: Maybe<(event: React.UIEvent<HTMLDivElement>) => void>; | ||
| /** Callback triggered when column is resized */ | ||
| onColumnResize?: Maybe<(column: CalculatedColumn<R, SR>, width: number) => void>; | ||
| /** Callback triggered when columns are reordered */ | ||
|
|
@@ -312,19 +314,22 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| const enableVirtualization = rawEnableVirtualization ?? true; | ||
| const direction = rawDirection ?? 'ltr'; | ||
|
|
||
| /** | ||
| * ref | ||
| */ | ||
| const gridRef = useRef<HTMLDivElement>(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<number | undefined>(undefined); | ||
| const [scrollToPosition, setScrollToPosition] = useState<PartialPosition | null>(null); | ||
| const [shouldFocusPosition, setShouldFocusPosition] = useState(false); | ||
| const [previousRowIdx, setPreviousRowIdx] = useState(-1); | ||
|
|
||
| const isColumnWidthsControlled = | ||
|
|
@@ -345,7 +350,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| [columnWidths] | ||
| ); | ||
|
|
||
| const [gridRef, gridWidth, gridHeight] = useGridDimensions(); | ||
| const { | ||
| columns, | ||
| colSpanColumns, | ||
|
|
@@ -381,6 +385,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| const [activePosition, setActivePosition] = useState<ActiveCellState | EditCellState<R>>( | ||
| getInitialActivePosition | ||
| ); | ||
| const { setScrollToPosition, scrollToPositionElement } = useScrollToPosition({ gridRef }); | ||
| const { shouldFocusPositionRef } = useShouldFocusPosition({ gridRef, activePosition }); | ||
|
|
||
| /** | ||
| * computed values | ||
|
|
@@ -496,19 +502,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| const selectHeaderCellLatest = useLatestFunc(selectHeaderCell); | ||
|
|
||
| /** | ||
| * effects | ||
| * Misc hooks | ||
| */ | ||
| useLayoutEffect(() => { | ||
| if (shouldFocusPosition) { | ||
| if (activePositionIsRow) { | ||
| focusRow(gridRef.current!); | ||
| } else { | ||
| focusCell(gridRef.current!); | ||
| } | ||
| setShouldFocusPosition(false); | ||
| } | ||
| }, [shouldFocusPosition, activePositionIsRow, gridRef]); | ||
|
|
||
| useImperativeHandle( | ||
| ref, | ||
| (): DataGridHandle => ({ | ||
|
|
@@ -634,16 +629,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| } | ||
| } | ||
|
|
||
| function handleScroll(event: React.UIEvent<HTMLDivElement>) { | ||
| const { scrollTop, scrollLeft } = event.currentTarget; | ||
| flushSync(() => { | ||
| setScrollTop(scrollTop); | ||
| // scrollLeft is nagative when direction is rtl | ||
| setScrollLeft(abs(scrollLeft)); | ||
| }); | ||
| onScroll?.(event); | ||
| } | ||
|
|
||
| function updateRow(column: CalculatedColumn<R, SR>, rowIdx: number, row: R) { | ||
| if (typeof onRowsChange !== 'function') return; | ||
| if (row === rows[rowIdx]) return; | ||
|
|
@@ -849,7 +834,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| // Avoid re-renders if the selected cell state is the same | ||
| scrollIntoView(getCellToScroll(gridRef.current!)); | ||
| } else { | ||
| setShouldFocusPosition(options?.shouldFocus === true); | ||
| shouldFocusPositionRef.current = options?.shouldFocus === true; | ||
| setActivePosition({ ...position, mode: 'ACTIVE' }); | ||
| } | ||
|
|
||
|
|
@@ -1034,7 +1019,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| const closeOnExternalRowChange = column.editorOptions?.closeOnExternalRowChange ?? true; | ||
|
|
||
| const closeEditor = (shouldFocus: boolean) => { | ||
| setShouldFocusPosition(shouldFocus); | ||
| shouldFocusPositionRef.current = shouldFocus; | ||
| setActivePosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'ACTIVE' })); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We are already setting state so react will batch should focus state. Wdyt?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. But then we'd have to set the state to
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is a good point. |
||
| }; | ||
|
|
||
|
|
@@ -1190,7 +1175,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| }} | ||
| dir={direction} | ||
| ref={gridRef} | ||
| onScroll={handleScroll} | ||
| onScroll={onScroll} | ||
| onKeyDown={handleKeyDown} | ||
| onCopy={handleCellCopy} | ||
| onPaste={handleCellPaste} | ||
|
|
@@ -1340,43 +1325,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr | |
| {/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */} | ||
| {renderMeasuringCells(viewportColumns)} | ||
|
|
||
| {scrollToPosition !== null && ( | ||
| <ScrollToCell | ||
| scrollToPosition={scrollToPosition} | ||
| setScrollToCellPosition={setScrollToPosition} | ||
| gridRef={gridRef} | ||
| /> | ||
| )} | ||
| {scrollToPositionElement} | ||
| </div> | ||
| ); | ||
| } | ||
|
|
||
| function getRowToScroll(gridEl: HTMLDivElement) { | ||
| return gridEl.querySelector<HTMLDivElement>(':scope > [role="row"][tabindex="0"]'); | ||
| } | ||
|
|
||
| function getCellToScroll(gridEl: HTMLDivElement) { | ||
| return gridEl.querySelector<HTMLDivElement>(':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); | ||
| } | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<React.RefObject<HTMLDivElement | null>, ScrollState>(); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a benefit of using a map instead of setting a local state inside the component?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you suggesting we keep using
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. function Test() {
const [resolvers, setResolvers] = useState<PromiseWithResolvers<void>>(() =>
Promise.withResolvers()
);
return (
<>
<button type="button" onClick={() => setResolvers(Promise.withResolvers())}>
New promise
</button>
<button type="button" onClick={() => resolvers.resolve()}>
Resolve
</button>
<Suspense fallback="Loading...">
<Inner promise={resolvers.promise} />
</Suspense>
</>
);
}
function Inner({ promise }: { promise: Promise<void> }) {
useState(() => {
console.log('init state');
});
use(promise);
return 'ok';
}In this example the This also happens when the suspense is triggered deeper in the tree: function Inner({ promise }: { promise: Promise<void> }) {
useState(() => {
console.log('init state');
});
// use(promise);
return (
<>
<InnerSide />
<InnerDeep promise={promise} />
</>
);
}
function subscribe() {
console.log('subscribe');
return () => {
console.log('unsubscribe');
};
}
function getSnapshot() {
console.log('get snapshot');
}
function InnerSide() {
useState(() => {
console.log('init state side');
});
useSyncExternalStore(subscribe, getSnapshot);
return 'side';
}
function InnerDeep({ promise }: { promise: Promise<void> }) {
useState(() => {
console.log('init state deep');
});
use(promise);
return 'ok';
}React only stops re-initializing states when it renders the tree under All that to say... I guess it's slightly better to have a single WeakMap?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does this mean a single WeakMap would be more performant or prevent some edge cases? |
||
|
|
||
| export function useScrollState(gridRef: React.RefObject<HTMLDivElement | null>): 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 <Activity mode="hidden"> | ||
| // 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); | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Scroll state now uses |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍