Search topics
Editable data grids a keyboard can't move through or edit
The cells of a table of values take only a click, which turns them into fields, with or without role="grid", so the keyboard can't reach a cell and the field a click opens has no name.
cell.onclick = () => edit(cell); The problem
Keyboard users can't reach a cell of the table to change it, and screen reader users aren't told that a value can be changed, or what the field that opens is for.
A shop's back office has a stock sheet: a table with three columns, Item, Price and In stock, and a row for each product. Its headers are th cells with scope, so the table itself is sound. Click a price and it turns into a field; type a new one, press Enter, and the table shows it. Put the mouse aside and press Tab: focus goes straight past the table to Save changes. The cells take no focus, so no price can be changed. Nothing says that one could be: Chrome exposes each price as a plain table cell, the role for content, not for a control. And when a click does open a cell, the field the script puts there has no name of its own: nothing in the code says which item or which column it edits.
The team then makes it a grid the quick way: role="grid" on the table, and nothing else. Chrome now exposes the prices as grid cells, a role that promises a widget with one Tab stop and arrow keys that move from cell to cell. Nothing delivers it: no cell takes focus, Tab still passes the table, and there is nowhere to press an arrow key. The field a click opens still has no name.
Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. A price is changed only by clicking a cell that takes no focus, so without a mouse no value can be changed at all. role="grid" changes nothing here: the cells still take no focus, and the arrow keys a grid promises do nothing.
Success criterion 4.1.2 Name, Role, Value asks that a control's name and role can be determined by software. The prices are controls: a click turns each into a field. In the first table, each is exposed only as a table cell, with nothing to say it can be changed. W3C documents this as failure F59, script that makes an element a control without giving it a role, there for a div or a span, and more broadly as failure F15, a custom control that doesn't use the accessibility API. With role="grid", the cells take a role that can be interactive, but no key ever reaches them. In both tables, the field a click puts in a cell is made by the script with no label, so it has no name: what a screen reader gets from the field itself is an edit field and a number, and nothing that says which cell it is.
Sheets like this sit behind many admin screens: stock and prices, timesheets, budgets, seating plans, bulk edits of orders. Often they are the only way to change those values, so a sheet the keyboard can't enter is a job someone can't do.
2 of 10 groups affected
- No vision (affected)
- Low vision (not affected)
- Colour vision (not affected)
- No hearing (not affected)
- Hard of hearing (not affected)
- No speech (not affected)
- Motor (affected)
- Reach, strength (not affected)
- Cognitive (not affected)
- Seizures (not affected)
- Limited manipulation Keyboard and switch users can't reach a single cell, so they can't change a price or a count at all: Tab goes straight past the table. Adding role="grid" changes nothing for them, since the cells still take no focus and no key moves into them.
- Without vision Screen reader users can read the table, but nothing tells them a value can be changed: each price is a plain table cell, or a grid cell that focus never reaches. When a click does open one, the field has no name of its own, only its value.
axe passes both tables. A td with a click handler gives a rule nothing to check, and role="grid" on a table with header cells is valid markup. No rule checks whether a cell takes focus or whether an arrow key moves it. axe's label rule would flag the field a click opens, but that field is not in the page until someone clicks, so a scan of the page as it loads never sees it.
The audit presses the keys. In each grid, it checks whether any cell or anything inside one takes focus from Tab; if none does, no cell can be reached, and that is reported. If one cell is the Tab stop, it focuses that cell and presses the arrow keys, Home and End; if focus never moves, the other cells can't be reached, and that is reported too. It sends to review a grid in which more than one cell is a Tab stop while the arrows do nothing, a table whose cells are drawn with a pointer, cell or text cursor but take no focus and hold no field, and a container named as a grid or sheet, built from divs, with cells like that. A table of clickable cells with no cursor of their own is not found, and no check opens a cell for editing to hear what the field is called: a person checks that.
The second table is what an AI fix can produce: it adds role="grid" and stops there. A fix can also put tabindex="0" on every cell, which gives each one a Tab stop but still no key that opens it.
Try it
Change the price of Paper cups to 0.15 using only the keyboard.
- Broken 1: press Tab. Focus goes past the table to Save changes, so no price can be reached. Click a price: the field that opens is read with no item or column.
- Broken 2: the same table, with role="grid" added. Tab still passes every cell, there is nowhere to press an arrow key, and the field a click opens is still read with no item or column.
- Apply the fix. In the grid, Tab once, move with the arrow keys, press Enter or F2 to edit, Enter to keep the change and Escape to cancel.
Broken Can you fix this?
Stock
| Item | Price | In stock |
|---|---|---|
| Oat milk | 2.40 | 36 |
| Paper cups | 0.12 | 500 |
| Espresso beans | 14.00 | 8 |
Screen reader says:
(Tab into the sheet, or click a price)
The fix
Make every editable cell one the keyboard can reach and open, and name the field that edits it by its row and column.
- 1Every editable cell can be reached and edited from the keyboard.
- 2Every field that edits a cell is named by its row and its column.
- 3A role="grid" gets everything it promises, one Tab stop, the arrow keys and a key into each cell; without them, put a field in each cell.
A table whose cells open only on click
<h2 id="stock-title">Stock</h2><table class="sheet" aria-labelledby="stock-title"> <thead> <tr> <th scope="col">Item</th> <th scope="col">Price</th> <th scope="col">In stock</th> </tr> </thead> <tbody> <tr> <th scope="row">Oat milk</th> <td>2.40</td> <td>36</td> </tr> <tr> <th scope="row">Paper cups</th> <td>0.12</td> <td>500</td> </tr> <tr> <th scope="row">Espresso beans</th> <td>14.00</td> <td>8</td> </tr> </tbody></table><button type="button">Save changes</button>A field in each cell
<h2 id="stock-title">Stock</h2><table class="sheet" aria-labelledby="stock-title"> <thead> <tr> <th scope="col" id="col-item">Item</th> <th scope="col" id="col-price">Price</th> <th scope="col" id="col-stock">In stock</th> </tr> </thead> <tbody> <tr> <th scope="row" id="row-oat">Oat milk</th> <td> <input value="2.40" aria-labelledby="row-oat col-price"> </td> <td> <input value="36" aria-labelledby="row-oat col-stock"> </td> </tr> <tr> <th scope="row" id="row-cups">Paper cups</th> <td> <input value="0.12" aria-labelledby="row-cups col-price"> </td> <td> <input value="500" aria-labelledby="row-cups col-stock"> </td> </tr> <tr> <th scope="row" id="row-beans">Espresso beans</th> <td> <input value="14.00" aria-labelledby="row-beans col-price"> </td> <td> <input value="8" aria-labelledby="row-beans col-stock"> </td> </tr> </tbody></table><button type="button">Save changes</button>Why this fix: Use native HTML first
The cells respond only to click and take no focus, so no key reaches them. A td is a table cell, so nothing says it can be changed, and the field the script makes has no label.
Each value is a native field in a table cell, so Tab reaches every one in reading order, typing changes it, and the browser does the rest: the click script goes. A field inside a table cell takes no name from the table's headers, so aria-labelledby names each one from its row's and its column's header: "Paper cups Price, edit, 0.12". The fields keep a visible border, so they still look like something to type in.
Both pass. A field in each cell needs no script and no new keys to learn: every field is a Tab stop, and typing, selecting text and the browser's own undo already work. Choose it for a sheet of a few rows, like this one, or any form laid out as a table. The grid puts the whole sheet behind one Tab stop, so it suits a sheet too long to tab through field by field, such as hundreds of rows of orders, or an app whose users expect spreadsheet keys. Then it needs all of it: one Tab stop, every key and a named field in each cell it opens.
The Authoring Practices also suggest that typing a letter or digit in a cell starts editing it, as spreadsheets do, and Page Up and Page Down for long grids; this sketch has neither. A column that can't be changed, such as a total worked out from the others, keeps its focus but takes aria-readonly="true" on its cells. aria-selected belongs only to a grid where cells or rows can be chosen, apart from the focus.
Don't put tabindex="0" on every cell of a grid instead of handling the keys: Tab then reaches every cell, but a grid's cells promise the arrow keys, and no key opens them. If people should Tab from cell to cell, give each cell a field, as in the first fix.
The structure the table already has is a separate matter. A grid built from divs needs a row around each row's cells, and one that draws only the rows in view needs aria-rowcount on the grid and aria-rowindex on each row, so that a screen reader can say where each cell is; both belong to the same topic as data tables with headers that are only bold text. Rows that open into child rows, such as threads in an inbox, are a tree grid, a pattern of its own, and a cell that holds a rich text editor needs that editor's own keys.
Fixing this with an AI coding assistant? Get this guide as Markdown, or get every guide in your coding agent.
Verify the fix
4 checks, no mouse.
Test with: Keyboard, Screen reader, Forced colors, Zoom
Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat after the sheet, its component or its library changes.
Limits
This is a starting pattern, not a guarantee for every application. The grid script is a minimal sketch: it has no typing to start editing, no Page Up or Page Down, no selection, no copy and paste, and no loading of rows on demand. Sorting by a column is a separate failure, covered by the topic on sortable tables saying which column is sorted, and which way. The same one-Tab-stop pattern, for a sidebar, is shown in tree views keyboards can't open and screen readers flatten. Data grid components in libraries vary: check that the one you use reaches every cell and handles every key before relying on it. Test the complete journey with your target assistive technology.
This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.
References
- ARIA APG: grid (opens in a new tab)
- ARIA APG: data-grids (opens in a new tab)
- ARIA APG: keyboard-interface (opens in a new tab)
- WCAG Understanding: keyboard (opens in a new tab)
- WCAG Understanding: name-role-value (opens in a new tab)
- WCAG Technique F15 (opens in a new tab)
- WCAG Technique F59 (opens in a new tab)
- w3.org: wai-aria-1.2 (opens in a new tab)
Keep this fixed as your code changes.
Turn guides like this into rules your coding agents and CI follow, then have the result retested with a keyboard and screen readers.