Skip to main content

useGridFocus

Tracks the active cell and turns keyboard input into movement clamped to the rows and columns you render. Give it the result of useRangeSelection and Shift+Arrow, Shift+Home, and Shift+End extend the range while plain movement collapses it to the new cell. Pass isCellEmpty and Ctrl+Arrow jumps by data region exactly as <OGrid> does. The hook is state plus a keydown handler factory; you draw the highlight and choose how focus works: a focusable container, or the same roving tabindex <OGrid> uses through getCellProps.

Live demo​

Tab into the table or click a cell, then use Arrow, Ctrl+Arrow, Shift+Arrow, Tab, Enter, Home/End, PageUp/PageDown
Live

Quick example​

import { useHeadlessGrid, useRangeSelection, useGridFocus } from '@alaarab/ogrid-react';

const grid = useHeadlessGrid({ columns, data, getRowId });
const range = useRangeSelection({ rowCount: grid.rows.length, colCount: grid.columns.length });
const focus = useGridFocus({
rowCount: grid.rows.length,
colCount: grid.columns.length,
pageSize: 10,
rangeSelection: range, // optional
// optional: Ctrl+Arrow jumps by data region instead of to the grid edge
isCellEmpty: (row, col) => {
const v = grid.getCellValue(grid.rows[row], grid.columns[col].columnId);
return v == null || v === '';
},
});

<div tabIndex={0} onKeyDown={focus.getKeyDownHandler()}>
<table>
{grid.rows.map((row, rowIdx) => (
<tr key={grid.getRowId(row)}>
{grid.columns.map((col, colIdx) => (
<td
key={col.columnId}
data-active={focus.activeCell?.row === rowIdx && focus.activeCell?.col === colIdx}
onMouseDown={() => {
focus.setActiveCell({ row: rowIdx, col: colIdx });
range.startRange(rowIdx, colIdx);
}}
/>
))}
</tr>
))}
</table>
</div>

Roving tabindex​

getCellProps(row, col) gives you the WAI-ARIA grid focus model that <OGrid> uses: the active cell is the grid's single tab stop and holds real DOM focus, so screen readers announce each cell as you move. Spread it on every cell and leave tabIndex off the container; key events from the focused cell bubble to the container's onKeyDown.

<div onKeyDown={focus.getKeyDownHandler()}>
<table role="grid" aria-label="Projects">
<tbody>
{grid.rows.map((row, rowIdx) => (
<tr key={grid.getRowId(row)}>
{grid.columns.map((col, colIdx) => (
<td key={col.columnId} {...focus.getCellProps(rowIdx, colIdx)}>
{grid.getCellValue(row, col.columnId)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>

It returns { tabIndex, ref, onFocus, onBlur }:

  • tabIndex is 0 on the active cell (on (0, 0) before any cell is active) and -1 on every other cell, so Tab enters the grid once and the next Tab leaves it.
  • onFocus makes a focused cell active: Tabbing in, clicking, or a screen reader moving focus all update activeCell.
  • While a cell has focus, focus follows the active cell after every move (keys, setActiveCell, moveTo*) with preventScroll; scroll the cell into view yourself. A move made while focus is elsewhere updates the tab stop without stealing focus.
  • ref is set only on the tab-stop cell. If you merge it with your own ref, call both.
  • Virtualized rows: a row that leaves the DOM can't keep focus. Keep the container focusable (tabIndex={-1}) and focus it when the active row unmounts, or keep the active row rendered.

If you'd rather keep the active cell visual only, skip getCellProps and make the container focusable (tabIndex={0}), as in the quick example above.

Parameters​

UseGridFocusParams

ParamTypeDefaultDescription
rowCountnumberrequiredVisible row count (the current page).
colCountnumberrequiredVisible column count.
pageSizenumber10Rows moved by PageUp / PageDown.
rangeSelectionUseRangeSelectionResultnoneWhen provided, movement drives the range: plain keys call startRange, Shift+keys call extendRange.
isCellEmpty(row, col) => booleannoneLets Ctrl+Arrow jump by data region (see below). Without it Ctrl+Arrow jumps to the grid edge.

Returns​

UseGridFocusResult

FieldTypeDescription
activeCellCellCoord | null{ row, col } or null before the first interaction.
setActiveCell(cell: CellCoord | null) => voidSet directly, typically from onMouseDown. Does not touch the range.
moveUp / moveDown / moveLeft / moveRight(n?: number) => voidMove by n (default 1), clamped at the edges.
moveToRowStart / moveToRowEnd() => voidFirst / last column of the current row.
moveToStart / moveToEnd() => void(0, 0) / (lastRow, lastCol).
getCellProps(row, col) => GridFocusCellPropsOptional roving tabindex: { tabIndex, ref, onFocus, onBlur } to spread on each cell. See Roving tabindex.
getKeyDownHandler() => (e) => voidReturns the handler to attach to your container's onKeyDown. It reads key, shiftKey, ctrlKey, metaKey and calls preventDefault for keys it consumes.

Ctrl+Arrow has no imperative counterpart; moveUp(n) and friends move by a fixed count, moveToStart / moveToEnd go to the corners.

Keys handled​

KeyActionWith Shift
Arrow keysMove one cellExtend the range
Ctrl+Arrow (Cmd on macOS)Jump to the edge of the current data region along that axis, or to the grid edge without isCellEmptyExtend the range to the same target
Tab / Shift+TabNext / previous column in the row. At the row's first or last cell the event is left to the browser so focus can leave the grid
Enter / Shift+EnterMove down / up one row
Home / EndFirst / last column of the rowExtend to the row edge, keeping the anchor
Ctrl+Home / Ctrl+End (Cmd on macOS)First / last cell of the gridExtend to the grid corner
PageUp / PageDownMove pageSize rowsExtend by pageSize rows

Behavior notes​

  • The first move focuses (0, 0). With no active cell, any movement key activates the top-left cell instead of moving relative to nothing.
  • Movement is clamped, never wrapped: ArrowRight at the last column stays put. Tab is the exception by design: it falls through to the browser at the row edge rather than trapping focus.
  • The active cell is not clamped when the grid shrinks. Reset it with setActiveCell(null) when the page, filter, or sort changes if a stale coordinate would be visible.
  • Attach the handler to the container, not to individual cells. With container focus, make the container focusable (tabIndex={0}); clicking a cell focuses it. With getCellProps the cells take focus and their key events bubble to the container.
  • Editing keys are not included. F2, typing to edit, Delete, and the clipboard and undo shortcuts are left for you to add around getKeyDownHandler() (see the landing-page demo for a combined handler). Skip the navigation handler while edit.editingCell is set so arrows move the caret inside the editor.
  • Ctrl+Arrow follows Excel's rules, computed by the same core helper <OGrid> uses (findCtrlArrowTarget), one axis at a time. From a non-empty cell whose neighbor in that direction is also non-empty, it stops at the last non-empty cell before a gap or the edge. From an empty cell, or one whose neighbor is empty, it skips the empty cells and lands on the next non-empty one, or the edge when nothing else is filled. <OGrid> counts null, undefined, and '' as empty; your isCellEmpty decides for this hook. Without isCellEmpty every cell counts as filled, so the jump lands on the grid edge. With Shift, the active cell moves to the target and the range extends to it, as with Shift+Arrow. A stale active cell outside the grid is clamped before the jump.

See also​