Skip to content

fix(tables): default header row cells to column scope - #2153

Closed
bbrzoska wants to merge 4 commits into
zendeskgarden:v9from
bbrzoska:bbrzoska/default-header-cell-scope
Closed

bbrzoska wants to merge 4 commits into
zendeskgarden:v9from
bbrzoska:bbrzoska/default-header-cell-scope

Conversation

@bbrzoska

@bbrzoska bbrzoska commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Description

Table.HeaderCell and Table.SortableCell inside Table.HeaderRow now default to scope="col". An explicit scope (or cellProps.scope on SortableCell) still wins. Header cells in body rows and plain rows render as they do today. "Behavior changes to review" below lists two other cases where output changes.

Detail

Both components render a th with no scope. That is valid for a simple table (WCAG H63), and browsers infer the role from context: for the README example, Chrome's accessibility tree shows columnheader. Tools that map roles from markup alone do not. Vitest browser mode's getByRole uses a bundled role engine (ivya) that returns cell for a th unless it has scope="col" or scope="row", so getByRole('columnheader') finds nothing (vitest-dev/vitest#8054, closed with "use scope"). Consumers currently add scope="col" to every header cell by hand. The default targets the usual layout, where a Table.HeaderRow holds column headers.

Where the header cell renders scope
directly in Table.HeaderRow col
Table.Row, Table.GroupRow, a plain tr none (unchanged)
inside a Garden cell's or table's content, such as a nested table none (unchanged)
with an explicit scope prop the given value

Body rows get no default on purpose. A th there may head its row, or, in a tbody row made of header cells, the columns below it. HTML's automatic header assignment already handles both layouts, and forcing row would break the second one.

Implementation: HeaderRow provides a boolean HeaderRowContext. Cell, HeaderCell, and Table reset it for everything they render (the provider wraps the cell element, so dangerouslySetInnerHTML still works). A table nested in a Garden cell of a header row therefore does not inherit the default. scope={undefined} counts as not given, so wrappers that forward an optional scope keep the default.

Tests were written first; the header-row default tests failed before the fix. They cover the default, explicit scope, scope={undefined}, no scope in body rows (a row of column headers in tbody, a row header, a plain tr), no scope inside cell content (native and Garden nested tables), dangerouslySetInnerHTML on Cell and HeaderCell, and the SortableCell default, body-row, and cellProps.scope cases. npm test -- packages/tables passes (47 tests), and eslint, prettier --check, and tsc are clean.

Behavior changes to review

Mixed header rows. By HTML's header assignment, a scope-less th whose row contains a data cell is not a column header. So:

  • A Table.HeaderRow with a Table.Cell in it (for example a select-all checkbox): under the HTML algorithm, no header in that row is a column header today (browser heuristics may differ). The default makes them column headers.
  • A multi-row thead whose second Table.HeaderRow pairs a label th with td values (the label is a row header today) now needs scope="row" on that label.

Maintainers: if the second case counts as breaking for v9, I can make the default opt-in instead (for example a Table prop). Let me know which you prefer.

Known limit: the context resets only at Garden cells and tables. A Table.HeaderCell or Table.SortableCell inside a native table inside a native td of a Table.HeaderRow still gets col. Pass scope explicitly there.

Checklist

  • 👌 design updates will be Garden Designer approved (add the designer as a reviewer)
  • 🌐 demo is up-to-date (npm start)
  • ⬅️ renders as expected with reversed (RTL) direction
  • ⚫ renders as expected in dark mode
  • 🤘 renders as expected with Bedrock CSS (?bedrock)
  • 💂‍♂️ includes new unit tests. Maintain existing coverage (always >= 96%)
  • ♿ tested for WCAG 2.1 AA accessibility compliance
  • 📝 tested in Chrome, Firefox, Safari, and Edge

🤖 Generated with Claude Code

`Table.HeaderCell` and `Table.SortableCell` render a `th` with no
`scope`. Browsers infer header semantics from context, but tools that
map roles from markup alone (for example the Vitest browser mode role
locator) read a scope-less `th` as a plain cell.

A cell in `Table.HeaderRow` now defaults to `scope="col"`, and a cell
in `Table.Row` to `scope="row"`. An explicit `scope` prop (or
`cellProps.scope` for `SortableCell`) still wins, a `th` outside a
Garden row gets no scope, and each `Table` resets the context so a
nested table does not inherit its host row's default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings September 30, 2026 19:03
@bbrzoska
bbrzoska requested a review from a team as a code owner September 30, 2026 19:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

bbrzoska and others added 2 commits September 30, 2026 12:07
A `th` in a body row is not always a row header: a `tbody` row of
header cells heads the columns below it, and HTML's automatic header
assignment already resolves both layouts. Forcing `scope="row"` there
would regress such tables, so only `Table.HeaderRow` sets a default
(`col`); header cells elsewhere keep no scope.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A table nested in a header row's cell (a native `table` or a Garden
`Table` inside a native cell) inherited the header row context, so its
header cells got `scope="col"`. `Table.Cell` and `Table.HeaderCell` now
reset the context around their content.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bbrzoska bbrzoska changed the title fix(tables): default header cell scope from the enclosing row fix(tables): default header row cells to column scope Sep 30, 2026
@coveralls

coveralls commented Sep 30, 2026 •

Copy link
Copy Markdown

Coverage Status

Coverage is 95.945% — bbrzoska:bbrzoska/default-header-cell-scope into zendeskgarden:v9. No base build found for zendeskgarden:v9.

Wrapping a cell's children in the context provider gave the `td`/`th`
a child even when none was passed, so `dangerouslySetInnerHTML` threw.
Put the provider around the cell element instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@bbrzoska
bbrzoska marked this pull request as draft September 30, 2026 19:15
@bbrzoska

Copy link
Copy Markdown
Member Author

Closing this. Table.HeaderCell was already exposed correctly: Chrome's accessibility tree and Playwright's getByRole both treat these header cells as columnheader without a scope. The mismatch came from Vitest's role engine, which is fixed upstream in vitest-dev/ivya#25 (a port of Playwright's th rule). An explicit scope is still supported and still takes precedence. Thanks, and sorry for the noise!

@bbrzoska bbrzoska closed this Sep 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Development

Successfully merging this pull request may close these issues.

3 participants