Table cell header calculation for AT


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.

Table cell native role algorithm
ConditionNVDA
IE
NVDA
Firefox
NVDA
Chrome
VoiceOver
Safari
TH scope=rowRowHeaderRowHeaderRowHeaderRowHeader
TH scope=colColHeaderColHeaderColHeaderColHeader
TH scope=rowgroupN/ARowHeaderRowHeaderRowHeader
TH scope=colgroupN/AColHeaderColHeaderColHeader
TD scope=rowN/ARowHeaderN/ARowHeader
TD scope=colN/AColHeaderN/AColHeader
TD scope=rowgroupN/ARowHeaderN/ARowHeader
TD scope=colgroupN/AColHeaderN/AColHeader
TH in THEADN/AN/AN/AColHeader
TH in top rowColHeaderN/AN/AColHeader (3)
TH in first columnRowHeaderN/AN/ARowHeader (4)
TH with TH to leftN/AN/AColHeaderN/A
TH with TD to leftN/AN/ARowHeaderN/A
TH with TH to rightN/AN/AColHeaderN/A
TH with TD to rightN/ARowHeaderRowHeaderN/A
TH with TD belowN/AColHeaderN/AN/A
TH with rowspan > 1N/ARowHeaderN/AN/A
Any other THDataCellColHeaderColHeaderDataCell
Any other TDDataCellDataCellDataCellDataCell

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:

Table cell ColHeaders and RowHeaders algorithm
ConditionNVDA
IE
NVDA
Firefox
NVDA
Chrome
VoiceOver
Safari
Cells with nativeRole=ColHeader listed in TD HEADERSAdded to ColHeadersAdded to ColHeadersIgnoredAdded to ColHeaders
Cells with nativeRole=RowHeader listed in TD HEADERSAdded to RowHeadersAdded to RowHeadersIgnoredAdded to ColHeaders
Cells with nativeRole=ColHeader above TDAdded to ColHeadersAdded to ColHeadersAdded to ColHeadersAdded to ColHeaders
Cells with nativeRole=ColHeader below TDIgnoredIgnoredAdded to ColHeadersIgnored
Cells with nativeRole=RowHeader to the left of TDAdded to RowHeadersAdded to RowHeadersAdded to RowHeadersAdded to RowHeaders
Cells with nativeRole=RowHeader to the right of TDIgnoredIgnoredAdded to RowHeadersIgnored

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:

  1. TD is always a cell (SCOPE is ignored and HEADERS references only work with TH elements)
  2. TH with SCOPE=ROW is a row header (SCOPE=ROWGROUP ignored)
  3. TH with SCOPE=COL is a column header (SCOPE=COLGROUP ignored)
  4. TH without SCOPE in first column is a row header
  5. TH without SCOPE in top row is a column header
  6. Any other TH is treated as a non-header cell
  7. 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:

  1. 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
  2. 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:

  1. TH or TD with SCOPE=COL or SCOPE=COLGROUP is a column header
  2. TH or TD with SCOPE=ROW or SCOPE=ROWGROUP is a row header
  3. TH with TD to the right is a row header
  4. TH with TD below is a column header
  5. TH not matching any of the above : guess based on TH ROWSPAN attribute
  6. TD without scope is a cell

Header calculation:

  1. 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
  2. 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:

  1. TD is always a cell (SCOPE and ROLE are ignored)
  2. TH with SCOPE=COL or SCOPE=COLGROUP is a column header
  3. TH with SCOPE=ROW or SCOPE=ROWGROUP is a row header
  4. TH with TH to left is a column header
  5. TH with TD to left is a row header
  6. TH with TH to right is a column header
  7. TH with TD to right is a row header
  8. Any other TH is a column header

Header calculation:

  1. Column headers for TD are all cells in same column with column header role
  2. Row headers for TD are all cells in same row with row header role
  3. 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:

  1. ARIA role if provided
  2. TH or TD with SCOPE=COL or SCOPE=COLGROUP is a column header
  3. TH or TD with SCOPE=ROW or SCOPE=ROWGROUP is a row header
  4. TD without SCOPE is a cell
  5. TH in a THEAD is a column header
  6. TH in top row and not in TFOOT is a column header
  7. TH in first column and not in THEAD is a row header
  8. 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:

  1. If a TD has a HEADERS attribute:
    • Add all cells listed in HEADERS attribute to column headers
    • Leave row headers empty
  2. 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.