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
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 }:
tabIndexis0on the active cell (on(0, 0)before any cell is active) and-1on every other cell, so Tab enters the grid once and the next Tab leaves it.onFocusmakes a focused cell active: Tabbing in, clicking, or a screen reader moving focus all updateactiveCell.- While a cell has focus, focus follows the active cell after every move
(keys,
setActiveCell,moveTo*) withpreventScroll; scroll the cell into view yourself. A move made while focus is elsewhere updates the tab stop without stealing focus. refis 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
| Param | Type | Default | Description |
|---|---|---|---|
rowCount | number | required | Visible row count (the current page). |
colCount | number | required | Visible column count. |
pageSize | number | 10 | Rows moved by PageUp / PageDown. |
rangeSelection | UseRangeSelectionResult | none | When provided, movement drives the range: plain keys call startRange, Shift+keys call extendRange. |
isCellEmpty | (row, col) => boolean | none | Lets Ctrl+Arrow jump by data region (see below). Without it Ctrl+Arrow jumps to the grid edge. |
Returns
UseGridFocusResult
| Field | Type | Description |
|---|---|---|
activeCell | CellCoord | null | { row, col } or null before the first interaction. |
setActiveCell | (cell: CellCoord | null) => void | Set directly, typically from onMouseDown. Does not touch the range. |
moveUp / moveDown / moveLeft / moveRight | (n?: number) => void | Move by n (default 1), clamped at the edges. |
moveToRowStart / moveToRowEnd | () => void | First / last column of the current row. |
moveToStart / moveToEnd | () => void | (0, 0) / (lastRow, lastCol). |
getCellProps | (row, col) => GridFocusCellProps | Optional roving tabindex: { tabIndex, ref, onFocus, onBlur } to spread on each cell. See Roving tabindex. |
getKeyDownHandler | () => (e) => void | Returns 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
| Key | Action | With Shift |
|---|---|---|
| Arrow keys | Move one cell | Extend 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 isCellEmpty | Extend the range to the same target |
| Tab / Shift+Tab | Next / 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+Enter | Move down / up one row | |
| Home / End | First / last column of the row | Extend to the row edge, keeping the anchor |
| Ctrl+Home / Ctrl+End (Cmd on macOS) | First / last cell of the grid | Extend to the grid corner |
| PageUp / PageDown | Move pageSize rows | Extend 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. WithgetCellPropsthe 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 whileedit.editingCellis 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>countsnull,undefined, and''as empty; yourisCellEmptydecides for this hook. WithoutisCellEmptyevery 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
useRangeSelectionfor Shift+Arrow range extensionuseInlineEditto open the editor on the active cell- Keyboard Navigation for the full shortcut set inside
<OGrid> - Accessibility