# Expandable table rows a keyboard can't open

Source: https://easeweb.dev/learn/expandable-rows
Topics: Complex widgets > Table rows that open into more rows can be opened from the keyboard (WCAG 1.3.1, 4.1.2, 2.1.1)
The fix: Either put a native button with aria-expanded in the row to open it, and end each child row's name with a word that says it is one, such as "subtask"; or build the full ARIA treegrid: rows with aria-level, aria-setsize and aria-posinset, aria-expanded on each row that opens, one Tab stop, and the arrow keys to move by row and by cell and to open and close rows.
Test with: keyboard, screen reader, forced colors, zoom
References: https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/ https://www.w3.org/WAI/ARIA/apg/patterns/treegrid/examples/treegrid-1/ https://www.w3.org/WAI/ARIA/apg/patterns/disclosure/ https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/ https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F15 https://www.w3.org/WAI/WCAG22/Techniques/failures/F59 https://www.w3.org/TR/wai-aria-1.2/#treegrid https://datatables.net/examples/api/row_details.html

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.

## What happens

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.

## Why it fails

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.

## Who is affected

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.

## What automation and AI miss

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](/learn/native-buttons) shows.

## Before: an arrow that only takes clicks

```html
<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>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks .subtask td:first-child { padding-left: 3.5rem; }
.tasks .toggle { position: absolute; left: 0.5rem; cursor: pointer; }
/* The arrow turns down while the task is open, by a class only. */
.tasks .toggle::before { content: ''; display: inline-block; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks .is-open .toggle::before { rotate: 45deg; }
```

```js
for (const toggle of document.querySelectorAll('.tasks .toggle')) {
  toggle.addEventListener('click', () => {
    const row = toggle.closest('tr');
    const open = !row.classList.contains('is-open');
    row.classList.toggle('is-open', open);
    for (const sub of document.querySelectorAll(`[data-parent="${row.id}"]`)) sub.hidden = !open;
  });
}
```

- 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.

## Before: role="treegrid" added to the same table

```html
<h2 id="tasks-title">Tasks</h2>
<table class="tasks" role="treegrid" 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>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks .subtask td:first-child { padding-left: 3.5rem; }
.tasks .toggle { position: absolute; left: 0.5rem; cursor: pointer; }
/* The arrow turns down while the task is open, by a class only. */
.tasks .toggle::before { content: ''; display: inline-block; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks .is-open .toggle::before { rotate: 45deg; }
```

```js
for (const toggle of document.querySelectorAll('.tasks .toggle')) {
  toggle.addEventListener('click', () => {
    const row = toggle.closest('tr');
    const open = !row.classList.contains('is-open');
    row.classList.toggle('is-open', open);
    for (const sub of document.querySelectorAll(`[data-parent="${row.id}"]`)) sub.hidden = !open;
  });
}
```

The only difference is the role. It promises a tree grid, with one Tab stop, the arrow keys, open and closed states and levels, and nothing delivers it:

- Tab still skips both arrows and stops on every task link that is showing.
- No row has `aria-level`, so nothing says a subtask is a level below its task.
- No task says whether it is open.
- axe's WCAG rules pass it.

## After: a button in the row, and subtasks that say so

```html
<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>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks .subtask td:first-child { padding-left: 3.5rem; }
.tasks .toggle { position: absolute; left: 0.5rem; cursor: pointer; }
/* A button's own border and background, reset; it keeps a size to press and a ring. */
.tasks .toggle { width: 1.25rem; height: 1.25rem; padding: 0; border: 0; background: none; color: inherit; }
.tasks .toggle:focus-visible { outline: 2px solid; outline-offset: 2px; }
/* The arrow is drawn from the state a screen reader reads. */
.tasks .toggle::before { content: ''; display: inline-block; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks .toggle[aria-expanded='true']::before { rotate: 45deg; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
```

```js
for (const toggle of document.querySelectorAll('.tasks .toggle')) {
  toggle.addEventListener('click', () => {
    const row = toggle.closest('tr');
    const open = toggle.getAttribute('aria-expanded') !== 'true';
    toggle.setAttribute('aria-expanded', String(open));
    for (const sub of document.querySelectorAll(`[data-parent="${row.id}"]`)) sub.hidden = !open;
  });
}
```

1. 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.
2. 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.
3. 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.
4. Keep `hidden` on 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.
5. Keep the table's headers, and draw the arrow from `aria-expanded` rather 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`.

## After: a full ARIA treegrid

```html
<h2 id="tasks-title">Tasks</h2>
<table class="tasks" role="treegrid" 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 role="row" aria-level="1" aria-posinset="1" aria-setsize="3" aria-expanded="true" tabindex="0">
      <td role="gridcell">
        <a href="/tasks/101" tabindex="-1">Launch the spring catalogue</a>
      </td>
      <td role="gridcell" tabindex="-1">Ana Ruiz</td>
      <td role="gridcell" tabindex="-1">Mar 14</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/102" tabindex="-1">Write product copy</a>
      </td>
      <td role="gridcell" tabindex="-1">Ben Carter</td>
      <td role="gridcell" tabindex="-1">Mar 7</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/103" tabindex="-1">Shoot product photos</a>
      </td>
      <td role="gridcell" tabindex="-1">Dana Park</td>
      <td role="gridcell" tabindex="-1">Mar 10</td>
    </tr>
    <tr role="row" aria-level="1" aria-posinset="2" aria-setsize="3" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/104" tabindex="-1">Renew the domain</a>
      </td>
      <td role="gridcell" tabindex="-1">Eli Novak</td>
      <td role="gridcell" tabindex="-1">Mar 20</td>
    </tr>
    <tr role="row" aria-level="1" aria-posinset="3" aria-setsize="3" aria-expanded="false" tabindex="-1">
      <td role="gridcell">
        <a href="/tasks/105" tabindex="-1">Plan the summer sale</a>
      </td>
      <td role="gridcell" tabindex="-1">Chris Moreau</td>
      <td role="gridcell" tabindex="-1">Apr 2</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="1" aria-setsize="2" tabindex="-1" hidden>
      <td role="gridcell">
        <a href="/tasks/106" tabindex="-1">Choose the discounts</a>
      </td>
      <td role="gridcell" tabindex="-1">Ana Ruiz</td>
      <td role="gridcell" tabindex="-1">Mar 25</td>
    </tr>
    <tr role="row" aria-level="2" aria-posinset="2" aria-setsize="2" tabindex="-1" hidden>
      <td role="gridcell">
        <a href="/tasks/107" tabindex="-1">Book the newsletter slot</a>
      </td>
      <td role="gridcell" tabindex="-1">Ben Carter</td>
      <td role="gridcell" tabindex="-1">Mar 28</td>
    </tr>
  </tbody>
</table>
<a href="/tasks/completed">Completed tasks</a>
```

```css
.tasks { border-collapse: collapse; }
.tasks th, .tasks td { padding: 0.5rem; text-align: left; }
.tasks tbody td:first-child { position: relative; padding-left: 2rem; }
.tasks [aria-level='2'] td:first-child { padding-left: 3.5rem; }
.tasks [aria-expanded] { cursor: pointer; }
/* The arrow is drawn from the state a screen reader reads. */
.tasks [aria-expanded] td:first-child::before { content: ''; position: absolute; left: 0.75rem; top: 0.9rem; padding: 3px; border: solid; border-width: 0 2px 2px 0; rotate: -45deg; }
.tasks [aria-expanded='true'] td:first-child::before { rotate: 45deg; }
.tasks tr:focus-visible, .tasks td:focus-visible, .tasks a:focus-visible { outline: 2px solid; outline-offset: -2px; }
```

```js
const grid = document.querySelector('[role="treegrid"]');
const body = grid.tBodies[0];
// The rows people can move to: every one not under a closed task.
const rows = () => [...body.rows].filter((row) => !row.hidden);
const level = (row) => Number(row.getAttribute('aria-level'));
// What focus lands on in each cell: the link in it, or the cell itself.
const targets = (row) => [...row.cells].map((cell) => cell.querySelector('a') ?? cell);
// A task's subtasks: the rows after it, up to the next row at its level.
function childrenOf(row) {
  const all = [...body.rows];
  const after = all.slice(all.indexOf(row) + 1);
  const end = after.findIndex((other) => level(other) <= level(row));
  return end === -1 ? after : after.slice(0, end);
}
function setOpen(row, open) {
  row.setAttribute('aria-expanded', String(open));
  for (const child of childrenOf(row)) child.hidden = !open;
}
// One Tab stop: the row or cell last moved to.
function moveTo(el) {
  if (!el) return;
  for (const other of body.querySelectorAll('[tabindex]')) other.tabIndex = -1;
  el.tabIndex = 0;
  el.focus();
}
grid.addEventListener('keydown', (event) => {
  const row = event.target.closest('tbody tr');
  if (!row) return;
  const list = rows();
  const r = list.indexOf(row);
  const cells = targets(row);
  // -1 while the row itself has focus.
  const c = cells.indexOf(event.target);
  const state = row.getAttribute('aria-expanded');
  // Down and Up keep to rows, or to the same column.
  const at = (other) => (other && c >= 0 ? targets(other)[c] : other);
  switch (event.key) {
    case 'ArrowDown': moveTo(at(list[r + 1])); break;
    case 'ArrowUp': moveTo(at(list[r - 1])); break;
    case 'ArrowRight':
      if (c === -1 && state === 'false') setOpen(row, true);
      else moveTo(cells[c + 1]);
      break;
    case 'ArrowLeft':
      if (c > 0) moveTo(cells[c - 1]);
      else if (c === 0) moveTo(row);
      else if (state === 'true') setOpen(row, false);
      else if (level(row) > 1) moveTo(list.slice(0, r).findLast((other) => level(other) < level(row)));
      break;
    case 'Home': moveTo(c === -1 || event.ctrlKey ? at(list[0]) : cells[0]); break;
    case 'End': moveTo(c === -1 || event.ctrlKey ? at(list.at(-1)) : cells.at(-1)); break;
    case 'Enter':
      if (event.target.matches('a')) return; // Enter on a link follows it.
      if (state !== null) setOpen(row, state === 'false');
      else row.querySelector('a').click();
      break;
    default:
      return;
  }
  event.preventDefault();
});
grid.addEventListener('click', (event) => {
  const row = event.target.closest('tbody tr');
  if (!row || event.target.closest('a')) return;
  const state = row.getAttribute('aria-expanded');
  if (state !== null) setOpen(row, state === 'false');
  moveTo(row);
});
```

1. Give every row `aria-level` for its depth, and `aria-posinset` and `aria-setsize` for its place among the rows beside it, so a screen reader can say that Book the newsletter slot is 2 of 2 at level 2.
2. Give each task `aria-expanded` for its state, and draw the arrow from it.
3. Make the whole tree grid one Tab stop: `moveTo()` gives `tabindex="0"` to the row or cell last moved to, and `-1` to the rest.
4. Let the link in each Task cell take focus in place of its cell, as the pattern says a cell holding a widget does.
5. Handle the keys of the ARIA Authoring Practices treegrid pattern, with rows focused first as in its email inbox example. Down and Up move to the next and previous row.
6. Right opens a closed task, and on an open task or a subtask moves into the row's cells, one at a time. Left closes an open task, goes back from a subtask to its task as the inbox example does (the pattern itself leaves focus on a row with no child rows), and from the first cell back to the row.
7. Home and End go to the first and last row, or to the ends of the row from a cell, and Ctrl + Home and Ctrl + End to the first and last row, in the same column from a cell.
8. Enter on a row opens or closes a task, or, on a row with nothing under it, follows its link, and Enter on a link follows it.
9. Open or close a task on a click anywhere in its row, outside its link.

This fixes both Befores and passes 1.3.1, 2.1.1 and 4.1.2.

## Which option to use

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.

## Implementation decisions

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.

## Verify the fix

1. Put the mouse aside and Tab into the table. Open every task and reach every subtask; in a treegrid, use the arrow keys, Home and End, and check that one Tab leaves it.
2. With a screen reader, move to each task that opens, and open and close it: it is announced as expanded or collapsed, and the arrow on screen matches.
3. Move to a subtask. In the plain table, its link is announced as a subtask; in a treegrid, the screen reader says the row's level and its position under its task, such as 2 of 2 at level 2.
4. In a treegrid, check every key against the treegrid pattern and its inbox example: Right opens a closed task and then moves into the row's cells, Left moves back out of the cells, closes an open task and then moves from a subtask to its task, and Enter on a subtask follows its link.
5. Turn on forced colors (Windows Contrast themes) and zoom to 200%. The arrows, the indentation of the subtasks and the focus ring are all still visible, and no row is cut off.

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.

## 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](/learn/tree-views), and a table whose cells are edited in place is covered in [editable data grids a keyboard can't move through or edit](/learn/editable-grids). A single section that opens and closes is covered in [show and hide buttons that don't say whether they are open](/learn/show-hide-buttons). Test the complete journey with your target assistive technology.

## What screen readers do (tested by hand by EaseWeb)

`aria-expanded`: Says whether a button’s menu or section is open.

| Screen reader | Browser and system | Press it | Tab to it |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
