Summary
This post describes how table cell headers for screen-readers are calculated.
- TH with SCOPE=ROW or SCOPE=COL is unambiguous and widely supported
- TD with SCOPE is non-conforming in HTML 5, and is ignored as a header on some browsers (works in Apple WebKit, but ignored in Chromium WebKit).
- On tables with headers in the top row, or first column, TH without SCOPE usually works.
- TD HEADERS is problematic because it assumes a single list of headers for each cell, but accessibility APIs expose row and column headers as separate properties
- On any other table, TH without SCOPE produces wildly varying results.
Native role for TH and TD
This table shows how native role for TH and TD elements is calculated by different browsers and screen-readers. It’s based on an inspection of screen reader and browser code (see below for full details).
Native role calculations starts with the most specific conditions at the top of the table and works downwards until a role is found. N/A indicates a condition not used in the role calculation by a specific AT.
Condition | NVDA IE |
NVDA Firefox |
NVDA Chrome |
VoiceOver Safari |
---|---|---|---|---|
TH scope=row | RowHeader |
RowHeader |
RowHeader |
RowHeader |
TH scope=col | ColHeader |
ColHeader |
ColHeader |
ColHeader |
TH scope=rowgroup | N/A | RowHeader |
RowHeader |
RowHeader |
TH scope=colgroup | N/A | ColHeader |
ColHeader |
ColHeader |
TD scope=row | N/A | RowHeader |
N/A | RowHeader |
TD scope=col | N/A | ColHeader |
N/A | ColHeader |
TD scope=rowgroup | N/A | RowHeader |
N/A | RowHeader |
TD scope=colgroup | N/A | ColHeader |
N/A | ColHeader |
TH in THEAD | N/A | N/A | N/A | ColHeader |
TH in top row | ColHeader |
N/A | N/A | ColHeader (3) |
TH in first column | RowHeader |
N/A | N/A | RowHeader (4) |
TH with TH to left | N/A | N/A | ColHeader |
N/A |
TH with TD to left | N/A | N/A | RowHeader |
N/A |
TH with TH to right | N/A | N/A | ColHeader |
N/A |
TH with TD to right | N/A | RowHeader |
RowHeader |
N/A |
TH with TD below | N/A | ColHeader |
N/A | N/A |
TH with rowspan > 1 | N/A | RowHeader |
N/A | N/A |
Any other TH | DataCell |
ColHeader |
ColHeader |
DataCell |
Any other TD | DataCell |
DataCell |
DataCell |
DataCell |
Notes
(1) This table shows the main interoperability issues, but is a simplification and doesn’t capture all of the implementation subtleties in edge cases.
(2) Some accessibility tree implementations (Firefox, Blink) only allow an element to have a single native role. This is an issue because table headers sometimes have multiple roles. For example:
- When using the TD headers attribute
- The TH at position 1,1 is sometimes both a row and column header
(3) Only if TH not enclosed by TFOOT
(4) Only if TH not enclosed by THEAD
Header calculation
Most accessibility APIs expose separate column headers and row headers properties for each cell. This allows screen readers to voice column headers when moving along a row, voice row headers when moving up or down a column, and provide separate commands to announce row or column header.
This table shows the column and row headers calculation for each TD cell, using the role calculated above:
Condition | NVDA IE |
NVDA Firefox |
NVDA Chrome |
VoiceOver Safari |
---|---|---|---|---|
Cells with nativeRole=ColHeader listed in TD HEADERS |
Added to ColHeaders |
Added to ColHeaders |
Ignored | Added to ColHeaders |
Cells with nativeRole=RowHeader listed in TD HEADERS |
Added to RowHeaders |
Added to RowHeaders |
Ignored | Added to ColHeaders |
Cells with nativeRole=ColHeader above TD |
Added to ColHeaders |
Added to ColHeaders |
Added to ColHeaders |
Added to ColHeaders |
Cells with nativeRole=ColHeader below TD |
Ignored | Ignored | Added to ColHeaders |
Ignored |
Cells with nativeRole=RowHeader to the left of TD |
Added to RowHeaders |
Added to RowHeaders |
Added to RowHeaders |
Added to RowHeaders |
Cells with nativeRole=RowHeader to the right of TD |
Ignored | Ignored | Added to RowHeaders |
Ignored |
What the specs say
Modern accessibility APIs expose separate ColHeaders
and RowHeaders
properties
for each cell:
There’s a detailed description of table cell header calculation in the HTML 5 spec.
There’s also a detailed description of cell header calculation in the HTML 4.01 spec, which differs from the HTML 5 algorithm.
Unfortunately, both the HTML 4.01 and 5.0 algorithms assume that row and column headers form
a single list of headers for a cell (as does the TD HEADERS attribute). That means that neither
algorithm works with the ColHeaders
and RowHeaders
properties exposed in current accessibility APIs.
Some implementations (Firefox and NVDA/IE) go to heroic lengths to guess whether items listed in TD HEADERS should be exposed to the API as column headers or row headers. Other implementations (Safari / VoiceOver) just assume they’re column headers.
How the implementations actually work
NVDA with IE
NVDA with Internet Explorer uses the MSAA accessibility API which requires NVDA to implement its own role and header calculations:
Role calculation:
- TD is always a cell (SCOPE is ignored and HEADERS references only work with TH elements)
- TH with SCOPE=ROW is a row header (SCOPE=ROWGROUP ignored)
- TH with SCOPE=COL is a column header (SCOPE=COLGROUP ignored)
- TH without SCOPE in first column is a row header
- TH without SCOPE in top row is a column header
- Any other TH is treated as a non-header cell
- ARIA role is ignored in TH role calculation
Note: NVDA/IE allows cells to have both row header and column header roles, which is useful in some type of table.
NVDA maintains lists of row headers and column headers for each cell:
- TD with HEADERS list
- THs listed in HEADERS list with row header role are added to the TD’s row header list
- THs listed in HEADERS list with column header role are added to the TD’s column header list
- TD without HEADERS
- Add all THs above TD with role = column header to TD column headers
- Add all THs to left of TD with role = row header to TD row headers
The main logic is in fillVBuf_helper_collectAndUpdateTableInfo, and the code is in the NVDA GitHub repository
NVDA with Firefox
NVDA with Firefox uses the IAccessible2 API, so the role and name calculation is done by Firefox and exposed through IAccessible2.
Role calculation:
- TH or TD with SCOPE=COL or SCOPE=COLGROUP is a column header
- TH or TD with SCOPE=ROW or SCOPE=ROWGROUP is a row header
- TH with TD to the right is a row header
- TH with TD below is a column header
- TH not matching any of the above : guess based on TH ROWSPAN attribute
- TD without scope is a cell
Header calculation:
- If TD HEADERs list specified
- Add all items in list with role = column header role to TD column headers
- Add all items in list with in same column with role != role header to TD column headers
- Add all items in list with role = row header role to TD row headers
- Add all items in list with in same row with role != column header to TD row headers
- If no TD HEADERs
- Add all cells above TD with role = column header to TD column headers
- Add all cells to left of TD with role = row header to TD row headers
The code is the Mozilla HTMLTableAccessible
class in accessible/html/HTMLTableAccessible.cpp
NVDA with Chrome
NVDA with Chrome uses the IAccessible2 API, so the role and name calculation is done by Chrome and exposed through IAccessible2.
Role calculation:
- TD is always a cell (SCOPE and ROLE are ignored)
- TH with SCOPE=COL or SCOPE=COLGROUP is a column header
- TH with SCOPE=ROW or SCOPE=ROWGROUP is a row header
- TH with TH to left is a column header
- TH with TD to left is a row header
- TH with TH to right is a column header
- TH with TD to right is a row header
- Any other TH is a column header
Header calculation:
- Column headers for TD are all cells in same column with column header role
- Row headers for TD are all cells in same row with row header role
- The TD HEADERS attribute is ignored
The main logic is in scanToDecideHeaderRole, and the code is in the Chromium AXTableCell class
Safari with Voiceover
Safari WebKit role calculation:
- ARIA role if provided
- TH or TD with SCOPE=COL or SCOPE=COLGROUP is a column header
- TH or TD with SCOPE=ROW or SCOPE=ROWGROUP is a row header
- TD without SCOPE is a cell
- TH in a THEAD is a column header
- TH in top row and not in TFOOT is a column header
- TH in first column and not in THEAD is a row header
- TH matching none of the above is a cell (i.e. not a header)
This means it’s possible to have TH’s in Safari which aren’t treated as headers. For example, any TH that’s outside THEAD and not in the first row or first column is treated as a TD.
Note: Safari allows cells to be both row header and column headers, which is useful in some type of table.
The WebKit header calculation builds up a list of row and column headers for each cell by:
- If a TD has a HEADERS attribute:
- Add all cells listed in HEADERS attribute to column headers
- Leave row headers empty
- If no HEADERS attribute
- Add all cells above TD with role = column header to TD column headers
- Add all cells to left of TD with role = row header to TD row headers
See the role calculation above for determination of cell and row headers.
Note The VoiceOver API exposes column headers (read when moving horizontally along a row) and row headers (read when moving up or down a table column) as separate properties. The HEADERS attribute always maps to column headers in VoiceOver (even for headers with row header role), so are not read when moving up or down a column.
The code is in the Safari AccessibilityTableCell class.