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:
- No
role="table" on the header/body wrapper — it degrades to a pure layout node.
- No
role="rowgroup" at all — gated on !hasPageHeader at src/components/Table/TableBody.tsx:161.
role="table" moves onto the View wrapping FlashList (TableBody.tsx:160-166), and only when hasRows.
- 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).
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
- On web (Chrome or Safari, wide window), open Settings → Workspaces → [workspace] → Categories.
- 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.
- 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 #97269 — analysis comment.
Problem
ARIA table semantics added in #93335 no longer apply to the workspace tables. On web with VoiceOver,
Ctrl + Option + Arrowcell 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.ListHeaderand adopted it in every workspace table — Categories, Distance rates, Company cards, Workspaces list, Rooms, Domains list — exactly the tables #93335 covered. That setshasPageHeader = trueatsrc/components/Table/Table.tsx:388, which now disables the semantics:<TableSemanticContainer isEnabled={isTableSemanticsEnabled}>→role="table"wrappingTableHeader(role="row") +TableBody(role="rowgroup").src/components/Table/Table.tsx:505gates it onisTableSemanticsEnabled && !tableListMetadata.hasPageHeader, sorole="table"is never emitted on the header/body run.Consequences for these tables:
role="table"on the header/body wrapper — it degrades to a pure layout node.role="rowgroup"at all — gated on!hasPageHeaderatsrc/components/Table/TableBody.tsx:161.role="table"moves onto theViewwrapping FlashList (TableBody.tsx:160-166), and only whenhasRows.<input>— now renders inside thatrole="table"element as FlashList'sListHeaderComponent(TableBody.tsx:241,TableBody.tsx:390).TableHeaderis 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 therole="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 norowgroup, is not a valid table in the browser accessibility tree — so VoiceOver drops table mode.Repro
role="table"around the header/body run, norole="rowgroup";role="table"sits on the FlashList container with the search bar inside it.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
maintoday 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.tsxcurrently 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 insiderole="table"(L902) and that there is norole="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:aria-hiddenrole="table", and its subtree contains only the header row and the data rows.Table.ListHeadercontent — title, buttons, and the search input — renders outsiderole="table".aria-rowcountequals data rows + 1 for the header row;aria-colcountequals the column count, +1 whenselectionEnabled.aria-rowindex={1}, the first data row isaria-rowindex={2}, and cells carry 1-basedaria-colindex.3. Same assertions across the page-header variants, since the structure changes between them:
selectionEnabledon, sticky header active (viaonChangeStickyIndex), and the empty / no-results state. In every case there must be no duplicate accessiblerole="table"or duplicate accessible header row.4. Keep the negative cases covered. Narrow layout (
useResponsiveLayoutmock atTableTest.tsx:392) and native platform (getPlatformmock atTableTest.tsx:141) must still emit no table roles — seetableAccessibility.ts.5.
At least one real workspace table asserts the same structure— dropped.tests/ui/WorkspaceCompanyCardsTableTest.tsxand its siblings mock@components/Tablewholesale, so they cannot assert DOM semantics without being rewritten. Every workspace table funnels through the sameTablecomponent, and the page-header path is what regressed, soTableTest.tsxis the right place to guard it.Follow-up from #97269 — analysis comment.