Skip to content

Commit 1dcce2a

Browse files
authored
DataGrid: split scroll/scroll to position/should focus position handling into hooks (Comcast#3986)
* DataGrid: split scroll/scroll to position/should focus position handling into hooks * rename * update onScroll prop doc * no hook, so we can inline the element * fix bad merge * avoid re-focusing when the layout effect re-mounts
1 parent 1e98765 commit 1dcce2a

10 files changed

Lines changed: 209 additions & 132 deletions

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -610,9 +610,9 @@ See the [`PositionChangeArgs`](#positionchangeargstrow-tsummaryrow) type in the
610610

611611
###### `onFill?: Maybe<(event: FillEvent<R>) => R>`
612612

613-
###### `onScroll?: Maybe<(event: React.UIEvent<HTMLDivElement>) => void>`
613+
###### `onScroll?: React.UIEventHandler<HTMLDivElement> | undefined`
614614

615-
Callback triggered when the grid is scrolled.
615+
Native DOM `onScroll` prop.
616616

617617
###### `onColumnResize?: Maybe<(column: CalculatedColumn<R, SR>, width: number) => void>`
618618

src/DataGrid.tsx

Lines changed: 33 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react';
1+
import { useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
22
import type { Key, KeyboardEvent } from 'react';
33
import { flushSync } from 'react-dom';
44

@@ -11,17 +11,22 @@ import {
1111
useColumnWidths,
1212
useGridDimensions,
1313
useLatestFunc,
14+
useScrollState,
15+
useScrollToPosition,
1416
useViewportColumns,
1517
useViewportRows,
16-
type HeaderRowSelectionContextValue
18+
type ActivePosition,
19+
type HeaderRowSelectionContextValue,
20+
type PartialPosition
1721
} from './hooks';
1822
import {
19-
abs,
2023
assertIsValidKeyGetter,
2124
canExitGrid,
2225
classnames,
2326
createCellEvent,
27+
focusCell,
2428
getCellStyle,
29+
getCellToScroll,
2530
getColSpan,
2631
getLeftRightKey,
2732
getNextActivePosition,
@@ -65,8 +70,6 @@ import EditCell from './EditCell';
6570
import GroupedColumnHeaderRow from './GroupedColumnHeaderRow';
6671
import HeaderRow from './HeaderRow';
6772
import { defaultRenderRow } from './Row';
68-
import type { PartialPosition } from './ScrollToCell';
69-
import ScrollToCell from './ScrollToCell';
7073
import { default as defaultRenderSortStatus } from './sortStatus';
7174
import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/cell';
7275
import {
@@ -105,6 +108,7 @@ type SharedDivProps = Pick<
105108
| 'aria-rowcount'
106109
| 'className'
107110
| 'style'
111+
| 'onScroll'
108112
>;
109113

110114
export interface DataGridProps<R, SR = unknown, K extends Key = Key> extends SharedDivProps {
@@ -189,8 +193,6 @@ export interface DataGridProps<R, SR = unknown, K extends Key = Key> extends Sha
189193
>;
190194
/** Function called whenever the active position is changed */
191195
onActivePositionChange?: Maybe<(args: PositionChangeArgs<NoInfer<R>, NoInfer<SR>>) => void>;
192-
/** Callback triggered when the grid is scrolled */
193-
onScroll?: Maybe<(event: React.UIEvent<HTMLDivElement>) => void>;
194196
/** Callback triggered when column is resized */
195197
onColumnResize?: Maybe<(column: CalculatedColumn<R, SR>, width: number) => void>;
196198
/** Callback triggered when columns are reordered */
@@ -302,19 +304,22 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
302304
const enableVirtualization = rawEnableVirtualization ?? true;
303305
const direction = rawDirection ?? 'ltr';
304306

307+
/**
308+
* ref
309+
*/
310+
const gridRef = useRef<HTMLDivElement>(null);
311+
305312
/**
306313
* states
307314
*/
308-
const [scrollTop, setScrollTop] = useState(0);
309-
const [scrollLeft, setScrollLeft] = useState(0);
315+
const { scrollTop, scrollLeft } = useScrollState(gridRef);
316+
const [gridWidth, gridHeight] = useGridDimensions({ gridRef });
310317
const [columnWidthsInternal, setColumnWidthsInternal] = useState(
311318
(): ColumnWidths => columnWidthsRaw ?? new Map()
312319
);
313320
const [isColumnResizing, setIsColumnResizing] = useState(false);
314321
const [isDragging, setIsDragging] = useState(false);
315322
const [draggedOverRowIdx, setDraggedOverRowIdx] = useState<number | undefined>(undefined);
316-
const [scrollToPosition, setScrollToPosition] = useState<PartialPosition | null>(null);
317-
const [shouldFocusPosition, setShouldFocusPosition] = useState(false);
318323
const [previousRowIdx, setPreviousRowIdx] = useState(-1);
319324

320325
const isColumnWidthsControlled =
@@ -335,7 +340,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
335340
[columnWidths]
336341
);
337342

338-
const [gridRef, gridWidth, gridHeight] = useGridDimensions();
339343
const {
340344
columns,
341345
colSpanColumns,
@@ -382,6 +386,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
382386
const {
383387
activePosition,
384388
setActivePosition,
389+
setPositionToFocus,
385390
activePositionIsInActiveBounds,
386391
activePositionIsInViewport,
387392
activePositionIsRow,
@@ -390,15 +395,16 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
390395
getActiveColumn,
391396
getActiveRow
392397
} = useActivePosition<R, SR>({
398+
gridRef,
393399
columns,
394400
rows,
395401
isTreeGrid,
396402
maxColIdx,
397403
minRowIdx,
398404
maxRowIdx,
399-
setDraggedOverRowIdx,
400-
setShouldFocusPosition
405+
setDraggedOverRowIdx
401406
});
407+
const { setScrollToPosition, scrollToPositionElement } = useScrollToPosition({ gridRef });
402408

403409
const defaultGridComponents = useMemo(
404410
() => ({
@@ -495,20 +501,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
495501
const selectHeaderCellLatest = useLatestFunc(selectHeaderCell);
496502

497503
/**
498-
* effects
504+
* Misc hooks
499505
*/
500-
useLayoutEffect(() => {
501-
if (shouldFocusPosition) {
502-
if (activePositionIsRow) {
503-
focusRow(gridRef.current!);
504-
} else {
505-
focusCell(gridRef.current!);
506-
}
507-
// eslint-disable-next-line react-hooks/set-state-in-effect
508-
setShouldFocusPosition(false);
509-
}
510-
}, [shouldFocusPosition, activePositionIsRow, gridRef]);
511-
512506
useImperativeHandle(
513507
ref,
514508
(): DataGridHandle => ({
@@ -636,16 +630,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
636630
}
637631
}
638632

639-
function handleScroll(event: React.UIEvent<HTMLDivElement>) {
640-
const { scrollTop, scrollLeft } = event.currentTarget;
641-
flushSync(() => {
642-
setScrollTop(scrollTop);
643-
// scrollLeft is nagative when direction is rtl
644-
setScrollLeft(abs(scrollLeft));
645-
});
646-
onScroll?.(event);
647-
}
648-
649633
function updateRow(column: CalculatedColumn<R, SR>, rowIdx: number, row: R) {
650634
if (typeof onRowsChange !== 'function') return;
651635
if (row === rows[rowIdx]) return;
@@ -810,8 +794,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
810794
// Avoid re-renders if the selected cell state is the same
811795
scrollIntoView(getCellToScroll(gridRef.current!));
812796
} else {
813-
setShouldFocusPosition(options?.shouldFocus === true);
814-
setActivePosition({ ...position, mode: 'ACTIVE' });
797+
const newPosition: ActivePosition = { ...position, mode: 'ACTIVE' };
798+
setActivePosition(newPosition);
799+
if (options?.shouldFocus) {
800+
setPositionToFocus(newPosition);
801+
}
815802
}
816803

817804
if (onActivePositionChange && !samePosition) {
@@ -994,8 +981,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
994981
const colSpan = getColSpan(column, lastFrozenColumnIndex, { type: 'ROW', row });
995982

996983
function closeEditor(shouldFocus: boolean) {
997-
setShouldFocusPosition(shouldFocus);
998-
setActivePosition(({ idx, rowIdx }) => ({ idx, rowIdx, mode: 'ACTIVE' }));
984+
const newPosition: ActivePosition = { idx: activePosition.idx, rowIdx, mode: 'ACTIVE' };
985+
setActivePosition(newPosition);
986+
if (shouldFocus) {
987+
setPositionToFocus(newPosition);
988+
}
999989
}
1000990

1001991
function onRowChange(row: R, commitChanges: boolean, shouldFocus: boolean) {
@@ -1133,7 +1123,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
11331123
}}
11341124
dir={direction}
11351125
ref={gridRef}
1136-
onScroll={handleScroll}
1126+
onScroll={onScroll}
11371127
onKeyDown={handleKeyDown}
11381128
onCopy={handleCellCopy}
11391129
onPaste={handleCellPaste}
@@ -1283,43 +1273,11 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
12831273
{/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */}
12841274
{renderMeasuringCells(viewportColumns)}
12851275

1286-
{scrollToPosition !== null && (
1287-
<ScrollToCell
1288-
scrollToPosition={scrollToPosition}
1289-
setScrollToCellPosition={setScrollToPosition}
1290-
gridRef={gridRef}
1291-
/>
1292-
)}
1276+
{scrollToPositionElement}
12931277
</div>
12941278
);
12951279
}
12961280

1297-
function getRowToScroll(gridEl: HTMLDivElement) {
1298-
return gridEl.querySelector<HTMLDivElement>('& > [role="row"][tabindex="0"]');
1299-
}
1300-
1301-
function getCellToScroll(gridEl: HTMLDivElement) {
1302-
return gridEl.querySelector<HTMLDivElement>('& > [role="row"] > [tabindex="0"]');
1303-
}
1304-
13051281
function isSamePosition(p1: Position, p2: Position) {
13061282
return p1.idx === p2.idx && p1.rowIdx === p2.rowIdx;
13071283
}
1308-
1309-
function focusElement(element: HTMLDivElement | null, shouldScroll: boolean) {
1310-
if (element === null) return;
1311-
1312-
if (shouldScroll) {
1313-
scrollIntoView(element);
1314-
}
1315-
1316-
element.focus({ preventScroll: true });
1317-
}
1318-
1319-
function focusRow(gridEl: HTMLDivElement) {
1320-
focusElement(getRowToScroll(gridEl), true);
1321-
}
1322-
1323-
function focusCell(gridEl: HTMLDivElement, shouldScroll = true) {
1324-
focusElement(getCellToScroll(gridEl), shouldScroll);
1325-
}

src/ScrollToCell.tsx

Lines changed: 0 additions & 43 deletions
This file was deleted.

src/hooks/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,7 @@ export * from './useGridDimensions';
55
export * from './useLatestFunc';
66
export * from './useRovingTabIndex';
77
export * from './useRowSelection';
8+
export * from './useScrollState';
9+
export * from './useScrollToPosition';
810
export * from './useViewportColumns';
911
export * from './useViewportRows';

src/hooks/useActivePosition.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { useState } from 'react';
1+
import { useLayoutEffect, useRef, useState } from 'react';
22

3+
import { focusCell, focusRow } from '../utils';
34
import type { CalculatedColumn, Position, StateSetter } from '../types';
45

5-
interface ActivePosition extends Position {
6+
export interface ActivePosition extends Position {
67
readonly mode: 'ACTIVE';
78
}
89

@@ -20,27 +21,31 @@ const initialActivePosition: ActivePosition = {
2021
};
2122

2223
export function useActivePosition<R, SR>({
24+
gridRef,
2325
columns,
2426
rows,
2527
isTreeGrid,
2628
maxColIdx,
2729
minRowIdx,
2830
maxRowIdx,
29-
setDraggedOverRowIdx,
30-
setShouldFocusPosition
31+
setDraggedOverRowIdx
3132
}: {
33+
gridRef: React.RefObject<HTMLDivElement | null>;
3234
columns: readonly CalculatedColumn<R, SR>[];
3335
rows: readonly R[];
3436
isTreeGrid: boolean;
3537
maxColIdx: number;
3638
minRowIdx: number;
3739
maxRowIdx: number;
3840
setDraggedOverRowIdx: StateSetter<number | undefined>;
39-
setShouldFocusPosition: StateSetter<boolean>;
4041
}) {
4142
const [activePosition, setActivePosition] = useState<ActivePosition | EditPosition<R>>(
4243
initialActivePosition
4344
);
45+
const [positionToFocus, setPositionToFocus] = useState<ActivePosition | EditPosition<R> | null>(
46+
null
47+
);
48+
const positionToFocusRef = useRef<ActivePosition | EditPosition<R>>(null);
4449

4550
/**
4651
* Returns whether the given position represents a valid cell or row position in the grid.
@@ -123,14 +128,33 @@ export function useActivePosition<R, SR>({
123128
mode: 'ACTIVE'
124129
};
125130
setActivePosition(newPosition);
126-
setShouldFocusPosition(false);
131+
setPositionToFocus(null);
127132
({ resolvedActivePosition, validatedPosition } = getResolvedValues(newPosition));
128133
}
129134
}
130135

136+
useLayoutEffect(() => {
137+
// Layout effects clean up when the component is replaced by a suspense fallback,
138+
// or when under <Activity mode="hidden">, then re-mounts when the suspense boundary cleans,
139+
// or when Activity switches back to `mode="visible"`.
140+
// So we use a ref to:
141+
// 1. avoid re-focusing after the effect re-mounts
142+
// 2. avoid re-rendering by not re-setting the state
143+
if (positionToFocus !== null && positionToFocus !== positionToFocusRef.current) {
144+
positionToFocusRef.current = positionToFocus;
145+
146+
if (positionToFocus.idx === -1) {
147+
focusRow(gridRef.current!);
148+
} else {
149+
focusCell(gridRef.current!);
150+
}
151+
}
152+
}, [positionToFocus, gridRef]);
153+
131154
return {
132155
activePosition: resolvedActivePosition,
133156
setActivePosition,
157+
setPositionToFocus,
134158
activePositionIsInActiveBounds: validatedPosition.isPositionInActiveBounds,
135159
activePositionIsInViewport: validatedPosition.isPositionInViewport,
136160
activePositionIsRow: validatedPosition.isRowInActiveBounds,

0 commit comments

Comments
 (0)