Skip to content

Fix a11y cell navigation - #11

Merged
Marvinkwame merged 19 commits into
mainfrom
fix-a11y-cell-navigation
Sep 2, 2026
Merged

Marvinkwame merged 19 commits into
mainfrom
fix-a11y-cell-navigation

Conversation

@Marvinkwame

Copy link
Copy Markdown
Owner

Summary

useTableA11y declares role="grid" and role="gridcell" and advertises the
WAI-ARIA Grid pattern, but did
not keep that contract. This fixes four defects and adds the 2D keyboard
navigation the role promises, as an opt-in flag so nothing breaks.

Also cuts the release: package.json3.1.0, lockfile synced, CHANGELOG
dated.

The bugs

1. The roving tabindex never roved. Nothing ever called .focus() — only
tabIndex changed. A keyboard user pressed ArrowDown and focus physically did
not move; they had to Tab again to reach the row the hook considered current.
This was the real defect, and the missing arrow keys were a symptom of it.
getRowProps/getCellProps now return a ref for element registration, and a
keypress focuses the newly-current element. Mounting a grid never steals focus —
only a keypress moves it.

2. aria-rowindex restarted on every page. It was computed against
getRowModel() (the current page) while aria-rowcount used the whole filtered
set, so on page 2 of a paginated grid screen readers announced
"row 1 of 50, row 2 of 50…" for rows that were actually 11–20. It now reads
getPrePaginationRowModel() — deliberately not getFilteredRowModel(),
which would report positions in original data order and discard the user's sort.
There is a test that fails against that wrong fix specifically.

3. Cells were not focusable and could not know their row. getCellProps
returned only role and aria-colindex — no tabIndex — so there was nowhere
for horizontal navigation to land, and getCellProps(columnIndex) had no way to
compute a per-cell tabindex.

4. Home/End were spec-incorrect, not merely incomplete. In the grid
pattern they move to the first/last cell within the current row, and
Ctrl+Home/End move to the first/last cell in the grid. They were jumping
rows.

Fixing bug 2 immediately broke the roving tabindex on paginated tables — zero
tabbable rows on page 2, a keyboard trap — because tabIndex had been derived
from the same index. Caught by a test written to prove it. The two are now
separate concerns: aria-rowindex is global, the roving tabindex is
page-relative, because you can only focus a row that is actually rendered.

The new option

const a11y = useTableA11y(table, { cellNavigation: true })

<td {...a11y.getCellProps(columnIndex, row.id)} />
Key Action
ArrowRight / ArrowLeft Move one column, clamped
ArrowDown / ArrowUp Move one row, keeping the column
Home / End First / last cell in the current row
Ctrl + Home / End First / last cell in the grid
PageDown / PageUp Move ten rows, clamped
Enter / Space Toggle selection of the focused cell's row

In cell mode the roving tabindex moves from the row to the cell, so exactly one
cell in the grid is tabbable, and the row's onKeyDown goes inert — without
that, a keypress bubbling from cell to row would move twice. There is a test for
that specific interaction.

Breaking changes

None. cellNavigation defaults to false, which preserves the previous
row-level behaviour and row-scoped Home/End. getCellProps(columnIndex)
still works with one argument; the rowId second argument is optional.

The one behaviour change is that focus now actually moves on arrow keys in row
mode too. That is the bug fix, not a new feature — a roving tabindex that
doesn't rove was never working as documented.

Testing

Tests   395 passed (366 baseline + 29 new)
Lint    tsc --noEmit, clean
Build   check-dts: OK, check-entry-deps: OK

New DOM-level tests in tests/a11y-focus.test.tsx render a real grid and assert
document.activeElement after keypresses — the state-level tests could not have
caught bug 1, since tabIndex was updating correctly the whole time.

Two tests initially passed vacuously (a sort-order assertion and a
selection toggle-off) and were rewritten so they fail against the wrong
implementation.

Known trade-off

registerElement returns a fresh closure per render, so React detaches and
reattaches refs each render. Correct, but not free on very large grids;
memoizing is a follow-up if it ever shows up in a profile.

Merge order

This branch carries the 3.1.0 version bump and a [3.1.0] — 2026-09-02
CHANGELOG section. #export-PR also has a 3.1.0 — unreleased section, so the
two will conflict in CHANGELOG.md (and in package.json if that branch is
bumped too). Merge the export PR first, then this one, and collapse the two
CHANGELOG sections into a single 3.1.0 entry.

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
tablecraft Ready Ready Preview Sep 2, 2026 9:21am UTC
tablecraft-g1vu Ready Ready Preview Sep 2, 2026 9:21am UTC

@Marvinkwame
Marvinkwame merged commit 65e4d84 into main Sep 2, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant