Search topics
Expandable table rows a keyboard can't open
A task table opens a task's subtasks only when the arrow in its row is clicked, so a keyboard can't open them and a screen reader isn't told the row opens.
<span class="toggle"></span> The problem
Keyboard users can't open a task in the table to reach its subtasks, and screen reader users aren't told which tasks open, whether they are open, or which rows are subtasks.
A team's project tracker lists its tasks in a table with three columns, Task, Assignee and Due, and a header row of th cells, so the table itself is sound. A task with subtasks has an arrow before its name: click the arrow and its subtasks unfold under it, indented, and the arrow turns down. Each task's name is a link to its page. Launch the spring catalogue is open, Renew the domain has no subtasks, and Plan the summer sale is closed. The arrow is an empty span drawn by CSS, with a click handler. Put the mouse aside and press Tab: focus moves from one task link to the next, the subtasks that happen to be showing included, then leaves the table for Completed tasks. Plan the summer sale can't be opened, so Book the newsletter slot can't be reached. A screen reader reads the visible rows as rows of a table, with nothing to say that a task opens, whether it is open, or that Write product copy is a subtask of the task above it.
This is how the DataTables plugin's own example of rows that open is written: the click handler is on a table cell, with no tabindex and no key handling. The team then makes it a tree grid the quick way: role="treegrid" on the table, and nothing else. For the keyboard, nothing changes: Tab still passes every arrow. A screen reader now calls the table a tree grid, but the arrow keys do nothing in it, no row says what level it is at, and no task says whether it is open.
Success criterion 2.1.1 Keyboard asks that everything the mouse can do, the keyboard can do. A task is opened only by clicking a span, which takes no focus, and the browser turns Enter and Space into a click only on a native control. The subtasks of a closed task can't be reached without a mouse. role="treegrid" changes nothing here: the arrows still take no focus, and the arrow keys that a tree grid promises do nothing.
Success criterion 4.1.2 Name, Role, Value asks that every control's name, role and state can be determined by software. The arrow is a control: a click on it opens the task. As an empty span it has no role, no name and no expanded or collapsed state, so a screen reader can't tell that a task opens, or an open task from a closed one. W3C documents this as failure F59, script that makes a span or div a control without giving it a role, and more broadly as failure F15, a custom control that doesn't use the accessibility API, or uses it only in part.
Success criterion 1.3.1 Info and Relationships asks that structure shown on screen is in the code. The indentation shows that Write product copy belongs to Launch the spring catalogue. In the table, nothing in the markup says so: every row is a row like any other, and the nesting is padding. A tree grid's rows have no group elements to nest in, so their levels come only from aria-level; with none, the role adds a grid of rows and still leaves out which task a subtask belongs to.
Tables whose rows open sit behind project and task trackers, order histories whose orders open into their parcels, budgets whose categories open into their lines, and file lists whose folders open into their files. The rows under a task are often the only way to reach them, and a row that can't be opened hides them from anyone who can't click it.
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 never land on the arrow, so a closed task's subtasks are out of reach. Adding role="treegrid" to the table changes nothing for them: the arrows still take no focus, and the arrow keys do nothing.
- Without vision Screen reader users hear the task names that happen to be showing, with nothing to say that a task opens, whether it is open, or that the rows under it are its subtasks rather than tasks of their own.
axe has nothing to check in the first table: a span with a click handler has no role, so no rule asks it for a name or a state, and the table's headers are sound. role="treegrid" on the same table passes axe's WCAG rules too: every row sits in the tree grid and every cell in a row. axe checks the shape of the roles (a row outside any table or grid is reported as aria-required-parent, and a treegrid with no rows as aria-required-children), never whether a key does anything or whether a row says its level.
The audit presses Tab through the page. An arrow in a table cell that opens a row, found by its class name (such as toggle, expander or DataTables' dt-control) and its pointer cursor, that Tab never reaches is reported, the cell itself included when the cell carries the class, as in DataTables. In each tree grid whose rows share one Tab stop, it focuses that stop and presses Down, Up, Right, End and Home; if focus never moves, the other rows can't be reached, and that is reported, as is a tree grid in which nothing takes focus at all. A row with aria-level whose next row is a level deeper and hidden, but which has no aria-expanded, is reported too. It sends to review a tree grid where several links are Tab stops and the arrows do nothing, which is the second table. An arrow with no telling class name is not found, and no check can tell whether the levels make sense to a listener.
The second table is what an AI fix can produce: it adds role="treegrid" and stops there. A fix can also give the span role="button" and tabindex="0", which Tab then reaches, but Enter and Space still do nothing, as div buttons that can't be reached with Tab shows.
Try it
Open Plan the summer sale and reach Book the newsletter slot using only the keyboard.
Broken 1: press Tab. It stops on the task links already showing, never on an arrow, so Plan the summer sale can't be opened.
Broken 2: the same table, with role="treegrid" added. Tab still skips both arrows, the arrow keys do nothing, and the panel never says a row's level or whether a task is open.
Apply a fix. With the button, Tab reaches each arrow and Enter opens its task; in the treegrid, the arrow keys move by row and open tasks, Right moves into a row's cells, and Home and End jump to the ends.
Broken Can you fix this?
Tasks
| Task | Assignee | Due |
|---|---|---|
| Launch the spring catalogue | Ana Ruiz | Mar 14 |
| Write product copy | Ben Carter | Mar 7 |
| Shoot product photos | Dana Park | Mar 10 |
| Renew the domain | Eli Novak | Mar 20 |
| Plan the summer sale | Chris Moreau | Apr 2 |
| Choose the discounts | Ana Ruiz | Mar 25 |
| Book the newsletter slot | Ben Carter | Mar 28 |
Screen reader says:
(Tab into the table)
The fix
Let the keyboard open each row, and say whether it is open.
An arrow that only takes clicks
<h2 id="tasks-title">Tasks</h2><table class="tasks" aria-labelledby="tasks-title"> <thead> <tr> <th scope="col">Task</th> <th scope="col">Assignee</th> <th scope="col">Due</th> </tr> </thead> <tbody> <tr id="task-101" class="is-open"> <td> <span class="toggle"></span> <a href="/tasks/101">Launch the spring catalogue</a> </td> <td>Ana Ruiz</td> <td>Mar 14</td> </tr> <tr class="subtask" data-parent="task-101"> <td> <a href="/tasks/102">Write product copy</a> </td> <td>Ben Carter</td> <td>Mar 7</td> </tr> <tr class="subtask" data-parent="task-101"> <td> <a href="/tasks/103">Shoot product photos</a> </td> <td>Dana Park</td> <td>Mar 10</td> </tr> <tr> <td> <a href="/tasks/104">Renew the domain</a> </td> <td>Eli Novak</td> <td>Mar 20</td> </tr> <tr id="task-105"> <td> <span class="toggle"></span> <a href="/tasks/105">Plan the summer sale</a> </td> <td>Chris Moreau</td> <td>Apr 2</td> </tr> <tr class="subtask" data-parent="task-105" hidden> <td> <a href="/tasks/106">Choose the discounts</a> </td> <td>Ana Ruiz</td> <td>Mar 25</td> </tr> <tr class="subtask" data-parent="task-105" hidden> <td> <a href="/tasks/107">Book the newsletter slot</a> </td> <td>Ben Carter</td> <td>Mar 28</td> </tr> </tbody></table><a href="/tasks/completed">Completed tasks</a>What is wrong3 points
- The arrow is a span that takes no focus and responds only to
click, so a closed task's subtasks are out of reach for the keyboard. - Nothing in the markup says a task opens, or whether it is open.
- Nothing says the rows after a task are its subtasks: the nesting is padding.
A button in the row, and subtasks that say so
<h2 id="tasks-title">Tasks</h2><table class="tasks" aria-labelledby="tasks-title"> <thead> <tr> <th scope="col">Task</th> <th scope="col">Assignee</th> <th scope="col">Due</th> </tr> </thead> <tbody> <tr id="task-101"> <td> <button type="button" class="toggle" aria-label="Subtasks" aria-expanded="true"></button> <a href="/tasks/101">Launch the spring catalogue</a> </td> <td>Ana Ruiz</td> <td>Mar 14</td> </tr> <tr class="subtask" data-parent="task-101"> <td> <a href="/tasks/102">Write product copy<span class="visually-hidden">, subtask</span></a> </td> <td>Ben Carter</td> <td>Mar 7</td> </tr> <tr class="subtask" data-parent="task-101"> <td> <a href="/tasks/103">Shoot product photos<span class="visually-hidden">, subtask</span></a> </td> <td>Dana Park</td> <td>Mar 10</td> </tr> <tr> <td> <a href="/tasks/104">Renew the domain</a> </td> <td>Eli Novak</td> <td>Mar 20</td> </tr> <tr id="task-105"> <td> <button type="button" class="toggle" aria-label="Subtasks" aria-expanded="false"></button> <a href="/tasks/105">Plan the summer sale</a> </td> <td>Chris Moreau</td> <td>Apr 2</td> </tr> <tr class="subtask" data-parent="task-105" hidden> <td> <a href="/tasks/106">Choose the discounts<span class="visually-hidden">, subtask</span></a> </td> <td>Ana Ruiz</td> <td>Mar 25</td> </tr> <tr class="subtask" data-parent="task-105" hidden> <td> <a href="/tasks/107">Book the newsletter slot<span class="visually-hidden">, subtask</span></a> </td> <td>Ben Carter</td> <td>Mar 28</td> </tr> </tbody></table><a href="/tasks/completed">Completed tasks</a>How it is fixed5 steps
- Make the arrow a native button named Subtasks. Tab reaches it, and Enter and Space open the task with the click the script already listens for.
- Give it
aria-expanded, and set it in the click handler. A screen reader says "Subtasks, button, collapsed" or "expanded" just before the task's own link. - End each subtask's link with hidden words, so it is read as "Write product copy, subtask", whether it is reached by Tab or by moving down the Task column. Its name still starts with the words on screen, which voice control users say.
- Keep
hiddenon a closed task's rows, so they stay out of the Tab order. The open task's rows follow its button, so the next Tabs go through them. - Keep the table's headers, and draw the arrow from
aria-expandedrather than a class.
This fixes both Befores and passes 1.3.1, 2.1.1 and 4.1.2. Several table libraries already draw the arrow this way: Element Plus, for one, renders it as a button with aria-expanded.
Why this fix: Use native HTML first
Both pass. For a table people open, read and leave, like this one, use the button: it is one native control per task that every browser, screen reader and voice control program already knows, it needs a few lines of script, and it leaves people to move with Tab and to read the table with their screen reader's table keys, as on any other table. Build the treegrid when the table is a workspace people go through row by row, with cells they act on, such as a ticket queue with a status or an assignee to change in each row, and too many rows to tab through one by one. Then it needs all of it: the roles, the levels and positions, the states, one Tab stop and every key, for rows and for cells.
A row that opens into one panel of details rather than child rows, as in DataTables' example, takes the same button: put it in the row's first cell, give it aria-expanded, and show the details in the next row, in one cell that spans the columns. The details are then read straight after the row they belong to.
Name the button for what it opens. "Subtasks" is short, and the task's name follows it in the same cell, so a screen reader moving by cell hears both. A voice control user who says "Click Subtasks" gets a number on each one to choose from; a name that includes the task, such as "Subtasks of Plan the summer sale", reaches one directly, at the cost of hearing the task's name twice. Keep the button and the link side by side, never one inside the other: HTML doesn't allow a link inside a button, and a screen reader reads whatever is inside a button only as part of the button's name.
In the treegrid, the ARIA Authoring Practices' own email inbox example focuses rows first and leaves cells to Right; it notes that a tree grid whose cells can't be focused would be a tree view instead. A cell that holds a control, such as a checkbox or a delete button, gives focus to that control rather than to itself, as the link does here. aria-selected is for a tree grid where choosing a row is a step apart from opening it, such as ticking several tasks before pressing Archive; set aria-multiselectable="true" on the treegrid when several can be chosen.
A table that draws only the rows in view, or loads a task's subtasks when it opens, can't let the browser count what isn't there: in a treegrid, keep aria-level, aria-posinset and aria-setsize on every row it draws, and add aria-rowcount on the treegrid and aria-rowindex on each row. Table components in libraries vary, and so do the examples sites copy from: check that the arrow is a button, says whether its row is open, and that the rows under it say they are, before relying on it.
Design ideas
A row can open quietly and still open from a key.
Five ways to draw a task that opens into its subtasks. Open each with the mouse, then put the mouse aside and Tab through them: three open from the keyboard, and two only with a mouse.
In the design file
- Draw every row that opens twice, closed and open, with its arrow turned in each.
- Draw the focus ring on the arrow, and keep the arrow shown without hovering.
- Write the arrow’s name beside it, such as “Subtasks”, since it becomes the name a screen reader reads.
- Write how a row under a task says it is one, in words, not only by indenting it.
Passes
Task Due Plan the summer sale Apr 2 Choose the discounts, subtask Mar 25 Book the newsletter slot, subtask Mar 28 Renew the domain Mar 20 A chevron in a round button The arrow sits in a 28px circle that fills a soft grey on hover and takes a ring on focus, so it reads as something to press, not a bullet. It turns down when the row is open, and stays shown whether or not the row is pointed at.Task Due Plan the summer sale Apr 2 Choose the discounts, subtask Mar 25 Book the newsletter slot, subtask Mar 28 Renew the domain Mar 20 A count pill that opens the row A pill after the name says how many subtasks there are, “2 subtasks”, in #3730a3 on #eef2ff, 8.88:1, with a small arrow that turns when it is open. People see what is inside before opening it, and the words on the pill are the name a screen reader reads and voice control listens for.Task Due Plan the summer sale Apr 2 Subtask Choose the discounts Mar 25 Subtask Book the newsletter slot Mar 28 Renew the domain Mar 20 Subtasks on a rail, labelled in small type The open rows hang from a thin line under their task, and each starts with “Subtask” in small grey type, #5f6368, 6.05:1 on white. The word says in text what the indent shows, for anyone who doesn’t read the indent, and a screen reader reading along the row hears it too.
Fails
Task Due Plan the summer sale Apr 2 Choose the discounts Mar 25 Book the newsletter slot Mar 28 Renew the domain Mar 20 An arrow that appears when the row is pointed atWCAG 2.1.1 The table stays clean until the pointer is on a row, when an arrow fades in before the name. Nothing else shows it, so Tab never stops on it and a touch screen has no hover to show it; keep the arrow shown, or show it on keyboard focus and always on touch screens.Rule of thumb: Make a gesture a shortcut, never the only way
Task Due Plan the summer sale Apr 2 Choose the discounts Mar 25 Book the newsletter slot Mar 28 Renew the domain Mar 20 The whole row opens on a click, with no arrowWCAG 2.1.1 The calmest table of all: no arrow, and a click anywhere on the row opens it, with the row shaded on hover as the only hint. The row is not a control, so Tab passes it and nothing says it opens; draw a visible arrow or count as the control, even when the whole row also takes the click.
Verify the fix
5 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 table, its component or its library changes.
How screen readers handle aria-expanded
Limits
This is a starting pattern, not a guarantee for every application. The scripts are minimal sketches: the treegrid has no Page Up or Page Down, no selection, no type-ahead and no loading of subtasks on demand. Tab always leaves the grid, where the pattern moves it on to the next link in the same row first. A sidebar of sections and pages with no columns is a tree view, covered in tree views keyboards can't open and screen readers flatten, and a table whose cells are edited in place is covered in editable data grids a keyboard can't move through or edit. A single section that opens and closes is covered in show and hide buttons that don't say whether they are open. 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: treegrid (opens in a new tab)
- ARIA APG: treegrid-1 (opens in a new tab)
- ARIA APG: disclosure (opens in a new tab)
- ARIA APG: keyboard-interface (opens in a new tab)
- WCAG Understanding: info-and-relationships (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)
- datatables.net: row_details (opens in a new tab)
EaseWeb service
Fixed it? Keep it from coming back.
I turn guides like this one into rules your coding agents follow and checks your CI runs. Then I retest by hand with a keyboard and screen readers, periodically.