Grid Keyboard

Keyboard

Full keyboard contract — 2D arrow nav, shift-extend, cmd-jump, Tab in and out, Cmd+A, Esc.

<PretableSurface> ships the full ARIA grid keyboard pattern out of the box. The grid is a single tab stop — header included: one Tab enters it, and the arrow keys navigate inside it. On the default tabBehavior="exit" one more Tab leaves. "wrap-rows" spends Tab on the walk to a release corner instead, so from a body cell leaving costs up to rows × columns presses — though from the header it is one press either way, whichever behavior is set. Nothing below needs additional wiring. Cmd/Ctrl is treated as either platform's primary modifier.

140 trades, well past the fold, with ID pinned left and Status pinned right. Tab into the grid below (or click a cell) and try the jump keys — Cmd/Ctrl + End, PageDown, Home / End — and watch the readout track the focus address while the viewport moves the minimum amount needed to reveal it, clear of the header and both pinned groups. from the first row puts the cursor on that column's header, and the readout says so:

Scroll follows focus

140 trades exceed the viewport, so jump keys reveal focus with minimal scroll instead of centering it.

.md

Full keyboard contract

Every key the grid above responds to:

KeyAction
↑ / ↓ / ← / →Move focus by one cell. Collapses selection.
Shift + ArrowExtend the active range by one cell.
Cmd/Ctrl + ArrowJump focus to grid edge in arrow direction.
Cmd/Ctrl + Shift + ArrowExtend range to grid edge.
Home / EndMove focus to first / last column in current row.
Cmd/Ctrl + Home / EndMove focus to first / last cell in grid.
PageUp / PageDownMove focus by viewport height.
Shift + PageUp/DownExtend range by viewport height.
Tab / Shift + Tab (default)Browser default — focus leaves the grid, forward or backward.
Tab (when tabBehavior="wrap-rows")Move right, wrap to next row at end; release at the last cell.
Shift + Tab (when tabBehavior="wrap-rows")Move left, wrap to prev row at start; release at the first cell.
Cmd/Ctrl + ASelect all cells.
Cmd/Ctrl + CCopy current selection (see Clipboard).
Cmd/Ctrl + VPaste clipboard TSV at the selection (see Paste); inert without an onPaste prop.
EscCollapse selection to focused cell.
EnterSelect the focused row as a full-width range; fires onRowActivate.
SpaceAs Enter, and additionally ticks the row's checkbox when rowSelectionColumn is enabled.

Every row above describes the cursor on a body row. On a column header several of these keys mean something else — Enter and Space sort rather than select, re-enters the body, and has nowhere to go — so read The column header alongside this table rather than in place of it.

When row grouping is active the grid becomes a treegrid: on a group row and take on collapse and expand inside the group column, and Enter / Space toggles the group instead of selecting it.

The column header

The header is a row of the same focus model, reached with from the first data row rather than with Tab. While the cursor is on it:

KeyAction
Move focus onto the focused column's header. From the header itself, nothing — it is the top.
Back to the first data row, same column.
← / →Move between header columns.
Home / EndFirst / last header column.
Enter / SpaceSort by this column, cycling descending → ascending → unsorted, exactly as clicking the header does.
Shift + EnterAdd this column to a multi-column sort, as shift-clicking does.
Alt + ↓Open this column's filter popover. Esc closes it and returns focus here.
Shift + F10Open this column's menu, where one is offered. ContextMenu does the same.
Tab / Shift + TabLeave the grid in one press, "wrap-rows" included — the wrap walk is a body behavior.

Alt + ↓ is scoped to the header: on a data cell Alt + ↓ is still an ordinary move-down, and no binding in the table above uses Alt.

Enter and Space are not handled by the grid's key handler at all. The header cell is a real <button> and the cursor puts DOM focus on it, so both keys fire its native activation — the same onClick a mouse click runs, Shift included, which is why Shift + Enter gets multi-column sort for free. Sorting from the key handler as well would cycle the sort twice per press.

Resizing and column reordering stay pointer affordances — there is no key for either. On a touch device resizing has no gesture either: the 4px strip is not rendered on a coarse pointer, and the filter funnel (plus the column menu, where one is offered) is drawn all the time in its place rather than waiting on a hover that never comes. See Resizing is a pointer affordance and The funnel on touch.

tabBehavior config

tabBehavior controls what Tab and Shift+Tab do inside the grid:

  • "exit" (default) — strict ARIA grid behavior. Tab and Shift+Tab fall through to the browser's default focus traversal, so one press leaves the grid and the page's normal order continues. The arrow keys are how you move inside.
  • "wrap-rows" — spreadsheet-style entry. Tab moves focus right; at the last column it wraps to the first column of the next row, and Shift+Tab is the reverse. At the two corners it releases: Tab on the last cell and Shift+Tab on the first fall through to the browser rather than sitting there.
tsx
<PretableSurface tabBehavior="wrap-rows" /* ... */ />

Both behaviors keep the grid a single tab stop, and both preserve the focused cell across a round trip: once Tab leaves, the grid remembers where it was, and coming back via Shift+Tab restores focus to the cell it left from. Under "wrap-rows" that is the release corner rather than wherever the walk started, since every press in between moved the cursor. From the header, Tab releases in one press in either direction, under both behaviors — a header cell has no wrap walk to do.

Single tab stop / focus model

<PretableSurface> follows the ARIA grid roving tabindex pattern. At any moment exactly one rendered cell has tabIndex={0}; every other cell — and every column header — has tabIndex={-1}.

Which cell gets the 0 depends on whether there is a focused cell to give it to:

  • Normally it is the focused cell from snapshot.focus. That may be a column header: snapshot.focus.ref is {kind: "header"} while the cursor is on one, and the header cell carries the 0.
  • When no rendered cell holds that address it falls to the first rendered body cell. That covers an untouched grid — snapshot.focus starts at {ref: null, columnId: null} and nothing seeds it until the user arrives — and it equally covers a focused row that has been scrolled out of the virtualization window.

The fallback lives on the body and only on the body. Two fallbacks would be two tab stops, and an untouched grid should hand the first Tab press a data cell rather than a sort button.

That second case is what makes the grid reachable at all. Being tabbable is deliberately not the same as being focused: the entry cell carries tabIndex={0} while snapshot.focus is still null and data-pretable-focused is still "false", so an untouched grid draws no focus ring, fires no onFocusChange, and does not scroll itself on page load. The address is seeded at the moment focus actually lands on the cell, which is also when the ring appears.

Controls rendered inside a cell — the row-select checkbox, the boolean toggle — are tabIndex={-1} for the same reason every non-focused cell is: you reach them by navigating to the cell, not by Tab. Space on the focused row is the keyboard route to the row-select checkbox. The header's Sort button, filter funnel and column menu follow the same rule, with and the keys in The column header as their route.

Reading the focus address

onFocusChange and grid.getState().focus report ref as one of three kinds, so code that switches on it has three cases:

refWhere the cursor is
nullNowhere — an untouched grid, or one whose focused row was deleted.
{kind: "data", rowId}A data cell, at columnId.
{kind: "group", groupId}A group header row, at columnId.
{kind: "header"}The column header of columnId. There is no row id: it is not a row.

A header ref must not be handed to the row model's indexOf or nearestVisibleRef — the row model has no header row to find, and would answer -1.

When the focused address changes (via keyboard, click, or programmatic grid.setFocus), a useLayoutEffect calls .focus() on the new cell's DOM node, so the browser's focus ring follows the engine state without flicker.

Scroll follows focus

The viewport scrolls to reveal the focused cell whenever the focus address changes. <PretableSurface> computes the offsets itself rather than calling Element.scrollIntoView(): the keyboard routinely moves focus to a row that is not in the DOM yet (nothing to call scrollIntoView on), and the browser has no idea a sticky pinned column group is covering the target.

The exact contract:

  • Minimal scroll, never centered. The viewport moves by the smallest amount that fully reveals the target — a target below the fold aligns to the bottom edge of the visible band, one above it aligns to the top.
  • An already-visible target scrolls nothing. Arrowing around inside the visible window leaves the viewport where it is.
  • "Revealed" means clear of the chrome, not just inside the box. The target is revealed clear of the sticky header row and of both the left- and right-pinned column groups, so a cell that would land underneath a pinned group is scrolled past it.
  • Focusing a pinned cell never scrolls horizontally. Pinned columns are on screen at every offset, so there is nothing to reveal.
  • A row taller than the visible band is top-aligned, so its first line is readable rather than its last.
  • The grid does not fight your own scrolling. Once a focus address has been satisfied it is never re-scrolled — scroll the focused cell out of view by hand and it stays out of view until focus moves again. The one exception is scrolling away in the same frame the grid is still resolving that address (it is waiting on the scroll event for its own offset, or on the target row's real height): there the grid can re-assert once more before it settles.

This is what makes the jump keys usable. PageUp / PageDown, Cmd/Ctrl + Arrow and Cmd/Ctrl + Home / End all land on a cell that is almost certainly outside the rendered window, so without scroll-into-view they look like they did nothing at all — it's why Cmd/Ctrl + End in the grid above lands cleanly at the bottom-right corner instead of appearing to do nothing.

.focus() is called with preventScroll: true deliberately — the browser's native focus scroll would fight the math above and knows nothing about the pinned groups.

Doing this yourself with usePretable

Consumers using <PretableSurface> don't have to manage tabIndex or scrolling. If you're building with usePretable and rendering your own JSX, you own both halves.

The tabIndex half mirrors the roving-tabindex pattern — with the fallback, which is the part that is easy to leave out and expensive to leave out:

tsx
const isFocused =
  snapshot.focus.rowId === row.id && snapshot.focus.columnId === col.id;
 
// `isFocused ? 0 : -1` ALONE is a WCAG 2.1.1 failure. `snapshot.focus` starts
// null, so with no fallback every cell resolves to -1 and there is no keyboard
// route into the grid — not "until the first click", but ever, because no
// keyboard interaction can produce that first click.
const hasFocusedCell =
  snapshot.focus.rowId !== null && snapshot.focus.columnId !== null;
const isEntryCell =
  !hasFocusedCell && rowIndex === firstRenderedRowIndex && colIndex === 0;
 
<div
  data-pretable-cell=""
  tabIndex={isFocused || isEntryCell ? 0 : -1} /* ... */
>

</div>;

Seed the address when focus lands on the entry cell — an onFocus on your container that calls grid.setFocus({rowId, columnId}) when the engine has no address yet — so the ring and the arrow keys agree with where the browser actually put focus. Do not seed it on mount: being tabbable and being focused are different states, and seeding on mount fires onFocusChange and scrolls the viewport for a grid nobody has touched.

The scrolling half you have to implement — the reveal math lives inside <PretableSurface> and is not exported from @pretable/react. What you do get is the state it runs on, all of it on renderSnapshot:

FieldWhat it gives you
rowMetricsrowCount, getHeight(i), getOffsetForIndex(i), getTotalHeight() over every visible row — rendered or not. This is what lets you scroll to a row that has no DOM node yet.
leadingHeightHeight of the unloaded region above the loaded window, px. 0 unless you are serving a window at a nonzero offset — see the third coordinate fact below.
pinnedLeftWidth / pinnedRightWidthWidths of the two sticky column groups, so you can keep the target clear of them.
columns[].left / columns[].widthColumn geometry in content coordinates.

Three coordinate facts you need to get the math right, all of which renderSnapshot.rows[].top already assumes:

  • Vertically, row offsets are relative to the scroll content, whose origin sits below the in-flow sticky header. So at scrollTop = S the unoccluded band in row coordinates is [S, S + (scrollerHeight − headerHeight)] — subtract the header height, or your target lands underneath it.
  • Horizontally, left is a content offset with the scrollable run already shifted past the left-pinned group. The pinned groups are sticky overlays on the viewport's edges, so at scrollLeft = SL the unoccluded band is [SL + pinnedLeftWidth, SL + viewportWidth − pinnedRightWidth].
  • On a windowed grid, rowMetrics is the odd one out. It is built over the rows you actually supplied, so its offsets are measured from the first loaded row, while rows[].top, totalHeight and the scroller's scrollTop are all measured from the top of the dataset. Add leadingHeight to anything that comes out of rowMetrics before you write it to the scroller, and subtract it from anything you hand in. It is 0 when you are not serving a window, which is why forgetting it looks harmless until resultMeta.window.start is not 0 — and then the grid scrolls to the top of the dataset instead of to your row.

Write the new offset only when it differs from the current one, and don't re-assert it for a focus address you have already revealed — otherwise you will yank the user back every time they scroll away.

Customizing key behavior

Pretable doesn't expose individual key handlers — there's no onArrowDown or onCopyKey prop. If you need to intercept (for example, to add Vim-style j/k bindings, or to swallow Cmd+A in a specific mode), wrap the surface in your own keydown listener and call event.preventDefault() on the keys you handle. Most consumers don't need this.

For programmatic moves from outside the grid (a button, a command palette, telemetry replay), call the model directly:

ts
grid.setFocus({ rowId, columnId });
grid.moveFocus("down");
grid.moveFocus("right", { extend: true }); // shift+right equivalent
grid.moveFocus("down", { jumpToEdge: true }); // cmd+down equivalent
grid.selectAllVisibleRows();
grid.clearSelection();

See API reference for the full method list.

See also

  • Filtering — the funnel's other two routes: hover on a fine pointer, always-drawn on a coarse one.
  • Column layout — why resize has no key and no touch gesture.
  • Selection — the cell-range model the keyboard mutates.
  • ClipboardCmd/Ctrl + C semantics and overrides.
  • Row grouping — the treegrid bindings and the group panel's chip keys.
  • API referencemoveFocus, setFocus, selectAll, clearSelection signatures.