Skip to content

Table semantics broken by the full-page-scroll table refactor — VoiceOver cell navigation no longer works #101043

Description

@MelvinBot

Problem

ARIA table semantics added in #93335 no longer apply to the workspace tables. On web with VoiceOver, Ctrl + Option + Arrow cell navigation no longer works and rows are not announced as "row N of M, column K".

This is a regression from #93877 ("Update table pages to scroll full page", merged 2026-08-24, c9a232f96d4d).

Root cause

#93877 introduced Table.ListHeader and adopted it in every workspace table — Categories, Distance rates, Company cards, Workspaces list, Rooms, Domains list — exactly the tables #93335 covered. That sets hasPageHeader = true at src/components/Table/Table.tsx:388, which now disables the semantics:

  • Before: <TableSemanticContainer isEnabled={isTableSemanticsEnabled}>role="table" wrapping TableHeader (role="row") + TableBody (role="rowgroup").
  • After: src/components/Table/Table.tsx:505 gates it on isTableSemanticsEnabled && !tableListMetadata.hasPageHeader, so role="table" is never emitted on the header/body run.

Consequences for these tables:

  1. No role="table" on the header/body wrapper — it degrades to a pure layout node.
  2. No role="rowgroup" at all — gated on !hasPageHeader at src/components/Table/TableBody.tsx:161.
  3. role="table" moves onto the View wrapping FlashList (TableBody.tsx:160-166), and only when hasRows.
  4. The page header — title, buttons, and the search <input> — now renders inside that role="table" element as FlashList's ListHeaderComponent (TableBody.tsx:241, TableBody.tsx:390).
  5. TableHeader is pulled out of the inline children (Table.tsx:389-398) and rendered as a virtualized/sticky FlashList item, with an extra <View style={styles.appBG}> around the role="row" when sticky.

A role="table" whose subtree is a scroll container full of non-row content (heading, buttons, search field) plus virtualized item wrappers, with no rowgroup, is not a valid table in the browser accessibility tree — so VoiceOver drops table mode.

Repro

  1. On web (Chrome or Safari, wide window), open Settings → Workspaces → [workspace] → Categories.
  2. Inspect the table in DevTools: no role="table" around the header/body run, no role="rowgroup"; role="table" sits on the FlashList container with the search bar inside it.
  3. With VoiceOver on, focus a table row and press Ctrl + Option + →/↓ — no cell-to-cell movement, no "row N of M, column K" announcement.

Expected

Table semantics work again on the workspace tables with a page header: a valid role="table" containing only the header row and the data rows, so VoiceOver enters table mode and cell navigation works.

Acceptance criteria

Updated 2026-09-13: the role="rowgroup" requirement in criterion 2 and the workspace-table requirement in criterion 5 were dropped — see the notes under each. Everything else stands.

The fix must land with unit tests that fail on main today and pass after the change. Structural ARIA is invisible to reviewers and to manual QA, which is how this regressed silently in the first place.

1. Fix the existing tests that lock in the broken shape. Three tests in tests/ui/TableTest.tsx currently assert the regressed structure, so they would block a correct fix rather than catch the bug:

  • TableTest.tsx:862 "should keep page-header rows in a persistent physical table ancestor" — asserts the page-header controls render inside role="table" (L902) and that there is no role="rowgroup" (L903).
  • TableTest.tsx:909 "should expose only data rows when a page-header table has no active column header" — same no-rowgroup assertion at L948.

Rewrite them to assert the correct tree; don't delete them.

2. New coverage for the page-header path — a table rendered with Table.ListHeader + Table.Header + Table.Body, web platform, wide layout:

  • Exactly one non-aria-hidden role="table", and its subtree contains only the header row and the data rows.
  • The Table.ListHeader content — title, buttons, and the search input — renders outside role="table".
  • aria-rowcount equals data rows + 1 for the header row; aria-colcount equals the column count, +1 when selectionEnabled.
  • The header row is aria-rowindex={1}, the first data row is aria-rowindex={2}, and cells carry 1-based aria-colindex.

Dropped: an inner role="rowgroup" wrapping the data rows. FlashList renders the column-header row and the data rows as flat siblings in one container, so no node wraps only the data rows. ARIA lets role="table" own role="row" children directly, so the table is valid without a rowgroup.

3. Same assertions across the page-header variants, since the structure changes between them: selectionEnabled on, sticky header active (via onChangeStickyIndex), and the empty / no-results state. In every case there must be no duplicate accessible role="table" or duplicate accessible header row.

4. Keep the negative cases covered. Narrow layout (useResponsiveLayout mock at TableTest.tsx:392) and native platform (getPlatform mock at TableTest.tsx:141) must still emit no table roles — see tableAccessibility.ts.

Note: the narrow-layout case is testable in this suite; the native case is not. shouldUseTableSemantics reads getPlatform() at module-evaluation time, so the platform cannot be toggled between tests in TableTest.tsx.

5. At least one real workspace table asserts the same structure — dropped. tests/ui/WorkspaceCompanyCardsTableTest.tsx and its siblings mock @components/Table wholesale, so they cannot assert DOM semantics without being rewritten. Every workspace table funnels through the same Table component, and the page-header path is what regressed, so TableTest.tsx is the right place to guard it.

Follow-up from #97269analysis comment.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions