Skip to content

Commit 90a8fe8

Browse files
authored
Make rows focusable, remove focus sink (#3963)
* Make rows focusable, remove focus sink Fixes #3957 * tweaks * add comment * smaller area * fix mousewheel clicks on GroupCell * event.buttons -> event.button * only set tabindex when in a treegrid * fix react warning
1 parent 089afb9 commit 90a8fe8

19 files changed

Lines changed: 187 additions & 234 deletions

src/DataGrid.tsx

Lines changed: 64 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
useCallback,
3-
useImperativeHandle,
4-
useLayoutEffect,
5-
useMemo,
6-
useRef,
7-
useState
8-
} from 'react';
1+
import { useCallback, useImperativeHandle, useLayoutEffect, useMemo, useState } from 'react';
92
import type { Key, KeyboardEvent } from 'react';
103
import { flushSync } from 'react-dom';
114

@@ -76,13 +69,7 @@ import type { PartialPosition } from './ScrollToCell';
7669
import ScrollToCell from './ScrollToCell';
7770
import { default as defaultRenderSortStatus } from './sortStatus';
7871
import { cellDragHandleClassname, cellDragHandleFrozenClassname } from './style/cell';
79-
import {
80-
focusSinkClassname,
81-
focusSinkHeaderAndSummaryClassname,
82-
rootClassname,
83-
viewportDraggingClassname
84-
} from './style/core';
85-
import { rowSelected, rowSelectedWithFrozenCell } from './style/row';
72+
import { rootClassname, viewportDraggingClassname } from './style/core';
8673
import SummaryRow from './SummaryRow';
8774

8875
export interface SelectCellState extends Position {
@@ -386,11 +373,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
386373
(): SelectCellState | EditCellState<R> => ({ idx: -1, rowIdx: minRowIdx - 1, mode: 'SELECT' })
387374
);
388375

389-
/**
390-
* refs
391-
*/
392-
const focusSinkRef = useRef<HTMLDivElement>(null);
393-
394376
/**
395377
* computed values
396378
*/
@@ -499,37 +481,19 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
499481
const selectCellLatest = useLatestFunc(selectCell);
500482
const selectHeaderCellLatest = useLatestFunc(selectHeaderCell);
501483

502-
/**
503-
* callbacks
504-
*/
505-
const focusCell = useCallback(
506-
(shouldScroll = true) => {
507-
const cell = getCellToScroll(gridRef.current!);
508-
if (cell === null) return;
509-
510-
if (shouldScroll) {
511-
scrollIntoView(cell);
512-
}
513-
514-
cell.focus({ preventScroll: true });
515-
},
516-
[gridRef]
517-
);
518-
519484
/**
520485
* effects
521486
*/
522487
useLayoutEffect(() => {
523488
if (shouldFocusCell) {
524-
if (focusSinkRef.current !== null && selectedPosition.idx === -1) {
525-
focusSinkRef.current.focus({ preventScroll: true });
526-
scrollIntoView(focusSinkRef.current);
489+
if (selectedPosition.idx === -1) {
490+
focusRow(gridRef.current!);
527491
} else {
528-
focusCell();
492+
focusCell(gridRef.current!);
529493
}
530494
setShouldFocusCell(false);
531495
}
532-
}, [shouldFocusCell, focusCell, selectedPosition.idx]);
496+
}, [shouldFocusCell, selectedPosition.idx, gridRef]);
533497

534498
useImperativeHandle(
535499
ref,
@@ -632,9 +596,13 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
632596
if (cellEvent.isGridDefaultPrevented()) return;
633597
}
634598

635-
if (!(event.target instanceof Element)) return;
636-
const isCellEvent = event.target.closest('.rdg-cell') !== null;
637-
const isRowEvent = isTreeGrid && event.target === focusSinkRef.current;
599+
const { target } = event;
600+
601+
if (!(target instanceof Element)) return;
602+
603+
const isCellEvent = target.closest('.rdg-cell') !== null;
604+
const isRowEvent = isTreeGrid && target.role === 'row';
605+
638606
if (!isCellEvent && !isRowEvent) return;
639607

640608
switch (event.key) {
@@ -734,7 +702,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
734702
function handleDragHandlePointerDown(event: React.PointerEvent<HTMLDivElement>) {
735703
// keep the focus on the cell
736704
event.preventDefault();
737-
if (event.pointerType === 'mouse' && event.buttons !== 1) {
705+
if (event.pointerType === 'mouse' && event.button !== 0) {
738706
return;
739707
}
740708
setDragging(true);
@@ -774,7 +742,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
774742

775743
function handleDragHandleClick() {
776744
// keep the focus on the cell but do not scroll
777-
focusCell(false);
745+
focusCell(gridRef.current!, false);
778746
}
779747

780748
function handleDragHandleDoubleClick(event: React.MouseEvent<HTMLDivElement>) {
@@ -870,20 +838,32 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
870838
const isRowSelected = selectedCellIsWithinSelectionBounds && idx === -1;
871839

872840
switch (key) {
873-
case 'ArrowUp':
874-
return { idx, rowIdx: rowIdx - 1 };
841+
case 'ArrowUp': {
842+
const nextRowIdx = rowIdx - 1;
843+
return {
844+
// avoid selecting header rows
845+
idx: idx === -1 && nextRowIdx < -topSummaryRowsCount ? 0 : idx,
846+
rowIdx: nextRowIdx
847+
};
848+
}
875849
case 'ArrowDown':
876850
return { idx, rowIdx: rowIdx + 1 };
877-
case leftKey:
878-
return { idx: idx - 1, rowIdx };
851+
case leftKey: {
852+
const nextIdx = idx - 1;
853+
return {
854+
// avoid selecting header rows
855+
idx: rowIdx < -topSummaryRowsCount && nextIdx < 0 ? 0 : nextIdx,
856+
rowIdx
857+
};
858+
}
879859
case rightKey:
880860
return { idx: idx + 1, rowIdx };
881861
case 'Tab':
882862
return { idx: idx + (shiftKey ? -1 : 1), rowIdx };
883863
case 'Home':
884-
// If row is selected then move focus to the first row
885-
if (isRowSelected) return { idx, rowIdx: minRowIdx };
886-
return { idx: 0, rowIdx: ctrlKey ? minRowIdx : rowIdx };
864+
// If row is selected then move focus to the first header row's cell.
865+
if (isRowSelected || ctrlKey) return { idx: 0, rowIdx: minRowIdx };
866+
return { idx: 0, rowIdx };
887867
case 'End':
888868
// If row is selected then move focus to the last row.
889869
if (isRowSelected) return { idx, rowIdx: maxRowIdx };
@@ -1149,7 +1129,8 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
11491129
lastFrozenColumnIndex,
11501130
onRowChange: handleFormatterRowChangeLatest,
11511131
selectCell: selectCellLatest,
1152-
selectedCellEditor: getCellEditor(rowIdx)
1132+
selectedCellEditor: getCellEditor(rowIdx),
1133+
isTreeGrid
11531134
})
11541135
);
11551136
}
@@ -1179,9 +1160,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
11791160
templateRows += ` repeat(${bottomSummaryRowsCount}, ${summaryRowHeight}px)`;
11801161
}
11811162

1182-
const isGroupRowFocused =
1183-
selectedPosition.idx === -1 && selectedPosition.rowIdx !== minRowIdx - 1;
1184-
11851163
return (
11861164
<div
11871165
role={role}
@@ -1204,18 +1182,10 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
12041182
)}
12051183
style={{
12061184
...style,
1207-
// set scrollPadding to correctly position non-sticky cells after scrolling
1208-
scrollPaddingInlineStart:
1209-
selectedPosition.idx > lastFrozenColumnIndex || scrollToPosition?.idx !== undefined
1210-
? `${totalFrozenColumnWidth}px`
1211-
: undefined,
1212-
scrollPaddingBlock:
1213-
isRowIdxWithinViewportBounds(selectedPosition.rowIdx) ||
1214-
scrollToPosition?.rowIdx !== undefined
1215-
? `${headerRowsHeight + topSummaryRowsCount * summaryRowHeight}px ${
1216-
bottomSummaryRowsCount * summaryRowHeight
1217-
}px`
1218-
: undefined,
1185+
// set scrollPadding to correctly scroll to non-sticky cells/rows
1186+
scrollPaddingInlineStart: totalFrozenColumnWidth,
1187+
scrollPaddingBlockStart: headerRowsHeight + topSummaryRowsCount * summaryRowHeight,
1188+
scrollPaddingBlockEnd: bottomSummaryRowsCount * summaryRowHeight,
12191189
gridTemplateColumns,
12201190
gridTemplateRows: templateRows,
12211191
'--rdg-header-row-height': `${headerRowHeight}px`,
@@ -1289,6 +1259,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
12891259
selectedCellIdx={isSummaryRowSelected ? selectedPosition.idx : undefined}
12901260
isTop
12911261
selectCell={selectCellLatest}
1262+
isTreeGrid={isTreeGrid}
12921263
/>
12931264
);
12941265
})}
@@ -1322,6 +1293,7 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
13221293
selectedCellIdx={isSummaryRowSelected ? selectedPosition.idx : undefined}
13231294
isTop={false}
13241295
selectCell={selectCellLatest}
1296+
isTreeGrid={isTreeGrid}
13251297
/>
13261298
);
13271299
})}
@@ -1334,24 +1306,6 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
13341306
{/* render empty cells that span only 1 column so we can safely measure column widths, regardless of colSpan */}
13351307
{renderMeasuringCells(viewportColumns)}
13361308

1337-
{/* extra div is needed for row navigation in a treegrid */}
1338-
{isTreeGrid && (
1339-
<div
1340-
ref={focusSinkRef}
1341-
tabIndex={isGroupRowFocused ? 0 : -1}
1342-
className={classnames(focusSinkClassname, {
1343-
[focusSinkHeaderAndSummaryClassname]: !isRowIdxWithinViewportBounds(
1344-
selectedPosition.rowIdx
1345-
),
1346-
[rowSelected]: isGroupRowFocused,
1347-
[rowSelectedWithFrozenCell]: isGroupRowFocused && lastFrozenColumnIndex !== -1
1348-
})}
1349-
style={{
1350-
gridRowStart: selectedPosition.rowIdx + headerAndTopSummaryRowsCount + 1
1351-
}}
1352-
/>
1353-
)}
1354-
13551309
{scrollToPosition !== null && (
13561310
<ScrollToCell
13571311
scrollToPosition={scrollToPosition}
@@ -1363,10 +1317,32 @@ export function DataGrid<R, SR = unknown, K extends Key = Key>(props: DataGridPr
13631317
);
13641318
}
13651319

1320+
function getRowToScroll(gridEl: HTMLDivElement) {
1321+
return gridEl.querySelector<HTMLDivElement>(':scope > [role="row"][tabindex="0"]');
1322+
}
1323+
13661324
function getCellToScroll(gridEl: HTMLDivElement) {
13671325
return gridEl.querySelector<HTMLDivElement>(':scope > [role="row"] > [tabindex="0"]');
13681326
}
13691327

13701328
function isSamePosition(p1: Position, p2: Position) {
13711329
return p1.idx === p2.idx && p1.rowIdx === p2.rowIdx;
13721330
}
1331+
1332+
function focusElement(element: HTMLDivElement | null, shouldScroll: boolean) {
1333+
if (element === null) return;
1334+
1335+
if (shouldScroll) {
1336+
scrollIntoView(element);
1337+
}
1338+
1339+
element.focus({ preventScroll: true });
1340+
}
1341+
1342+
function focusRow(gridEl: HTMLDivElement) {
1343+
focusElement(getRowToScroll(gridEl), true);
1344+
}
1345+
1346+
function focusCell(gridEl: HTMLDivElement, shouldScroll = true) {
1347+
focusElement(getCellToScroll(gridEl), shouldScroll);
1348+
}

src/GroupCell.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,20 +40,19 @@ function GroupCell<R, SR>({
4040

4141
return (
4242
<div
43+
key={column.key}
4344
role="gridcell"
4445
aria-colindex={column.idx + 1}
4546
aria-selected={isCellSelected}
46-
tabIndex={tabIndex}
47-
key={column.key}
47+
// tabIndex={undefined} prevents clicks on the cell
48+
// from stealing focus from the row.
49+
// onMouseDown={preventDefault} would break mousewheel clicks
50+
tabIndex={tabIndex === -1 ? undefined : tabIndex}
4851
className={getCellClassname(column)}
4952
style={{
5053
...getCellStyle(column),
5154
cursor: isLevelMatching ? 'pointer' : 'default'
5255
}}
53-
onMouseDown={(event) => {
54-
// prevents clicking on the cell from stealing focus from focusSink
55-
event.preventDefault();
56-
}}
5756
onClick={isLevelMatching ? toggleGroup : undefined}
5857
onFocus={onFocus}
5958
>

src/GroupRow.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
22
import { css } from 'ecij';
33

44
import { RowSelectionContext, type RowSelectionContextValue } from './hooks';
5-
import { classnames, getRowStyle } from './utils';
5+
import { classnames } from './utils';
66
import type { BaseRenderRowProps, GroupRow } from './types';
77
import { SELECT_COLUMN_KEY } from './Columns';
88
import GroupCell from './GroupCell';
@@ -44,6 +44,7 @@ function GroupedRow<R, SR>({
4444
isRowSelectionDisabled: _isRowSelectionDisabled,
4545
...props
4646
}: GroupRowRendererProps<R, SR>) {
47+
const isPositionOnRow = selectedCellIdx === -1;
4748
// Select is always the first column
4849
const idx = viewportColumns[0].key === SELECT_COLUMN_KEY ? row.level + 1 : row.level;
4950

@@ -64,15 +65,16 @@ function GroupedRow<R, SR>({
6465
aria-setsize={row.setSize}
6566
aria-posinset={row.posInSet + 1} // aria-posinset is 1-based
6667
aria-expanded={row.isExpanded}
68+
tabIndex={isPositionOnRow ? 0 : -1}
6769
className={classnames(
6870
rowClassname,
6971
groupRowClassname,
7072
`rdg-row-${rowIdx % 2 === 0 ? 'even' : 'odd'}`,
71-
selectedCellIdx === -1 && rowSelectedClassname,
73+
isPositionOnRow && rowSelectedClassname,
7274
className
7375
)}
7476
onMouseDown={handleSelectGroup}
75-
style={getRowStyle(gridRowStart)}
77+
style={{ gridRowStart }}
7678
{...props}
7779
>
7880
{viewportColumns.map((column) => (

src/HeaderCell.tsx

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -45,21 +45,15 @@ const resizeHandleClassname = `rdg-resize-handle ${resizeHandle}`;
4545

4646
const cellDraggableClassname = 'rdg-cell-draggable';
4747

48-
const cellDragging = css`
48+
const cellDraggingOrOver = css`
4949
@layer rdg.HeaderCell {
5050
background-color: var(--rdg-header-draggable-background-color);
5151
}
5252
`;
5353

54-
const cellDraggingClassname = `rdg-cell-dragging ${cellDragging}`;
54+
const cellDraggingClassname = `rdg-cell-dragging ${cellDraggingOrOver}`;
5555

56-
const cellOver = css`
57-
@layer rdg.HeaderCell {
58-
background-color: var(--rdg-header-draggable-background-color);
59-
}
60-
`;
61-
62-
const cellOverClassname = `rdg-cell-drag-over ${cellOver}`;
56+
const cellOverClassname = `rdg-cell-drag-over ${cellDraggingOrOver}`;
6357

6458
const dragImageClassname = css`
6559
@layer rdg.HeaderCell {
@@ -336,7 +330,7 @@ function ResizeHandle<R, SR>({
336330
const isRtl = direction === 'rtl';
337331

338332
function onPointerDown(event: React.PointerEvent<HTMLDivElement>) {
339-
if (event.pointerType === 'mouse' && event.buttons !== 1) {
333+
if (event.pointerType === 'mouse' && event.button !== 0) {
340334
return;
341335
}
342336

src/HeaderRow.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ function HeaderRow<R, SR, K extends React.Key>({
6262
direction
6363
}: HeaderRowProps<R, SR, K>) {
6464
const [draggedColumnKey, setDraggedColumnKey] = useState<string>();
65+
const isPositionOnRow = selectedCellIdx === -1;
6566

6667
const cells = [];
6768
for (let index = 0; index < columns.length; index++) {
@@ -98,9 +99,7 @@ function HeaderRow<R, SR, K extends React.Key>({
9899
aria-rowindex={rowIdx} // aria-rowindex is 1 based
99100
className={classnames(
100101
headerRowClassname,
101-
{
102-
[rowSelectedClassname]: selectedCellIdx === -1
103-
},
102+
isPositionOnRow && rowSelectedClassname,
104103
headerRowClass
105104
)}
106105
>

0 commit comments

Comments
 (0)