Data Table Without Header Cells
ID |
html.table_headers |
Severity |
high |
Remediation Complexity |
trivial |
Remediation Risk |
low |
Remediation Effort |
low |
Resource |
Reliability |
Language |
Html |
Tags |
accessibility, reliability |
Rationale
A header cell is what makes a table readable without seeing it. A screen reader announces the
header along with each cell ("Role: Owner"), so a table of nothing but <td> is read as a flat run
of values whose meaning was in the top row the user cannot see. Bold text in the first row looks
like a header and is not one.
<table> <!-- FLAW - every cell is a td -->
<tr><td>Name</td><td>Role</td></tr>
<tr><td>Ada</td><td>Owner</td></tr>
</table>
<table> <!-- OK - the first row is headers -->
<tr><th scope="col">Name</th><th scope="col">Role</th></tr>
<tr><td>Ada</td><td>Owner</td></tr>
</table>
<table role="presentation"> <!-- OK - declared as layout, not data -->
<tr><td>Menu</td><td>Content</td></tr>
<tr><td>Footer</td><td>Legal</td></tr>
</table>
<table> <!-- OK - a wrapper, not a grid of data -->
<tr><td>The whole page body lives in this one cell.</td></tr>
</table>
<form> <!-- OK - a form's field layout -->
<table>
<tr><td>Name</td><td><input name="name"></td></tr>
<tr><td>Address</td><td><input name="address"></td></tr>
</table>
</form>
<table> <!-- OK - panes of content, not values -->
<tr>
<td><h3>Billing Info</h3><p>Ada Lovelace, 12 Mill Lane</p></td>
<td><h3>Shipping Info</h3><p>Ada Lovelace, 12 Mill Lane</p></td>
</tr>
<tr>
<td><h3>Payment Method</h3><p>Card</p></td>
<td><h3>Shipping Method</h3><p>Standard</p></td>
</tr>
</table>
<table> <!-- OK - a label and its value, row by row -->
<tr><td><strong>Name</strong></td><td>Ada Lovelace</td></tr>
<tr><td><strong>Phone</strong></td><td>555-0100</td></tr>
</table>
Only a data table is asked for headers, and the rule takes the table’s word for what it is. A
table is layout when it declares role="presentation" or role="none", the two roles that take an
element out of the accessibility tree; such a table has no headers by definition, and declaring the
role is the correct fix for it rather than adding headers.
A table is data when it is not layout and it reads as a grid of data: at least two rows, at least
two cells in one of them, at least one <td> carrying content of its own, and no table nested
inside it. That shape is what separates a data table from the layout wrapper that legacy pages and
HTML email are built from - a <table><tr><td> around a block of content, sometimes stacked into a
column, sometimes nested two deep. Such a wrapper has no header row because it holds no data, and
it rarely declares a role, so a rule keyed on nothing but a missing <th> would fire on every one
of them.
Three more shapes reach that grid threshold without holding data, and are left alone for the same
reason. A table inside a <form> whose cells hold the form’s controls is a field layout — a label
in one cell, the control for it in the next — which was how a form was aligned before CSS and is
still how most templates write one. A cell whose content is a block of page content — a heading,
a paragraph, an address — is a pane rather than a value: a data cell holds a scalar, so an <h3>
and a <p> inside one say the table is arranging content, which is what every HTML e-mail’s
two-column body does.
And a table whose every row is a label and its value is a description list written as a table: each
value is already named beside it, row by row, so there is no column of values for a header to name.
Two such rows reach the threshold, which is why an e-mail’s details block, a printed form and a
report’s summary panel land on it. The label has to be written as one — text ending in a colon
(<td>Total issues found:</td>) or content wholly inside an emphasis element
(<td><strong>Phone</strong></td>) — so an ordinary two-column table of plain values is still read
as data and still reported.
Anything in between is left alone: a table whose rows come from a template expression
(<tbody>{{rows}}</tbody>), one holding a custom element, a <template> or a <slot> whose
rendered markup is very often the header row itself, and one whose role is settled at render time.
A cell carrying role="columnheader" or role="rowheader" is a header cell without the tag, and
answers for one.
One shape is worth naming as a limitation: a table whose rows are generated from a single source row
(<tr th:each="m : ${team}">, v-for, *ngFor) is not judged, because two rows are part of reading
as a grid and the file only shows one. The shape here is not the shape a user meets - the same reason
generated markup is left alone throughout this family - and it is what keeps this rule off the
one-cell and single-column wrappers that legacy pages are full of. A generated table with no header
row is therefore a miss; html.table_cell_headers and html.th_id_or_scope skip it for the same
reason.
Remediation
Mark the header row with <th> and say which cells each header heads:
<table>
<caption>The team</caption>
<thead>
<tr><th scope="col">Name</th><th scope="col">Role</th></tr>
</thead>
<tbody>
<tr><td>Ada</td><td>Owner</td></tr>
</tbody>
</table>
If the table only places things on the page, declare role="presentation" instead: it holds no data,
so headers would be noise, and the role takes the grid out of the accessibility tree.