Skip to content

feat: structural accessibility for data table - #5082

Open
oleksandrzavarzin-callstack wants to merge 6 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/structural-accessibility-for-DataTable
Open

feat: structural accessibility for data table#5082
oleksandrzavarzin-callstack wants to merge 6 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:feat/structural-accessibility-for-DataTable

Conversation

@oleksandrzavarzin-callstack

@oleksandrzavarzin-callstack oleksandrzavarzin-callstack commented Aug 28, 2026

Copy link
Copy Markdown

Motivation

DataTable's structure was purely visual: bare Views and TouchableRipples with no roles, indices or header relationships, so a screen reader conveyed no table context at all. A user heard "159" with no way to know it was the Calories column of row 3.

Changes

  • Web semantics: role="table" with aria-rowcount/aria-colcount, role="row" with aria-rowindex, role="columnheader" with aria-sort, role="cell" with aria-colindex.
  • Native fallback: table roles map to no accessibility trait on iOS or Android, so a row is announced as one item: "Dessert, Frozen yogurt, Calories, 159, row 3 of 6". It falls back to per-cell focus when a row holds interactive or non-text content, so nothing becomes unreachable. nativeFocusMode overrides the choice.
  • Non-actionable rendering: rows, cells and titles with no touch handler render a plain View.
  • Sort state: aria-sort on web, folded into the accessible name on native, with an announcement on change since focus stays on the header. New sortAccessibilityLabels localizes the wording.
  • Reanimated: the sort indicator moves off legacy Animated, matching Switch and Checkbox. Reduced motion is suppressed via ReduceMotion.Always instead of a manual snap, and 150ms becomes theme.motion.duration.short3 (same value).
  • Pagination: real control names, localizable through one labels prop. Data-dependent values are functions, so pluralization stays with the app's own i18n.
  • Columns: optional columns describes width and alignment once instead of repeating style on a title and every cell. New align composes with numeric, which is unchanged and not deprecated.
  • Text scaling: numberOfLines is honoured exactly at every font scale; the default is one line at the default scale and unclamped above it.
  • Tokens: separators move from surfaceVariant to outlineVariant, matching Divider.

Button gains aria-expanded, needed by the rows-per-page anchor which never reported that it opens a menu.

Breaking changes

Rows, cells and titles without a touch handler render a plain View. They lose the ripple, the web hover background and keyboard focus, and no longer report a disabled state.

// Before (v5): announced as a disabled control
<DataTable.Row>
  <DataTable.Cell>{item.name}</DataTable.Cell>
</DataTable.Row>

// After (v6): pass a handler if the row is meant to be pressable
<DataTable.Row onPress={() => select(item)}>
  <DataTable.Cell>{item.name}</DataTable.Cell>
</DataTable.Row>

Other important changes:

  • Rows are announced as a single item on native, and need rowCount and firstRowIndex for correct positions when paginating.
  • Pagination default labels changed, so tests querying the old placeholders need updating.
  • numeric now applies tabular figures, the one visual change to existing tables.

Related issue

Child of the "Modernize non-standard components" effort; no issue exists yet.

Test plan

  • yarn typescript, yarn lint clean
  • yarn test 806 passed, 167 snapshots. The 2828-line snapshot is replaced with 8 small per-component snapshots plus named contract assertions, since the old blob is where the aria-disabled regression sat unnoticed.
  • Web: verified against the live accessibility tree. Correct roles, counts and indices, aria-sort cycling, and no aria-disabled on any row, cell or title.
  • Android: TalkBack on an emulator, cross-checked with adb shell uiautomator dump. Tapping a row focuses that row, each header is its own stop, and a row containing an interactive element (e.g. Checkbox) falls back to per-cell focus.
  • iOS: Accessibility Inspector against the Simulator.
  • Reduced motion, 200% font scale, and RTL mirroring.

Screenshots:

Screenshot 2026-08-28 at 16 16 50 Screenshot 2026-08-28 at 16 13 27 Screenshot 2026-08-28 at 16 17 54

@oleksandrzavarzin-callstack
oleksandrzavarzin-callstack marked this pull request as ready for review August 28, 2026 13:52
const publish = useLatestCallback(() => setHeaderLabels?.(labels));
const signature = labels.join(' ');

React.useEffect(publish, [publish, signature]);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Publishing the column names through an effect makes the table's ARIA indices a second-render value. Server-rendered output (the docs site is one, rspress.config.ts:122 aliases to react-native-web) gets aria-rowcount="6" instead of 7, and the header row and the first data row both get aria-rowindex="1" - duplicate row indices. DataTable already reads column position from ColumnIndexContext without an effect; can the header names go the same way?

Comment on lines +155 to +156
const label =
ariaLabel ?? (isWeb ? undefined : composeCellLabel({ columnLabel, value }));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try const label = ariaLabel ?? (cellIsFocusUnit ? composeCellLabel({ columnLabel, value }) : undefined) - a cell wrapping a Checkbox currently gets aria-label="Pick", which buries the checkbox's own role and state behind the column name.

'aria-rowindex':
index == null ? undefined : index + 1 + (table?.hasHeader ? 1 : 0),
}),
accessible: accessible ?? (rowIsFocusUnit || undefined),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try accessible: accessible ?? rowIsFocusUnit - the || undefined leaves the prop unset, which is exactly when Pressable applies its own default of true, so a pressable row swallows the Checkbox inside it on iOS.

Comment thread src/components/DataTable/DataTable.tsx Outdated
Comment on lines +199 to +202
return child.props.index === undefined
? React.cloneElement(child, { index: firstRowIndex + offset })
: child;
});

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Try the provider pattern you already wrote for columns (DataTableColumnsContext.tsx:36-44) - cloneElement means a consumer's <NameRow /> wrapper gets no index and silently loses its aria-rowindex. It is also the React.Children problem #4954 names.

@oleksandrzavarzin-callstack

Copy link
Copy Markdown
Author

@JKobrynski Thank you for the review!
I've pushed the fixes to all of your comments. Please review the PR again

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants