Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions PULL_REQUEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
## Summary:

Resolves [#30973](https://github.com/react/react-native/issues/30973), [#30974](https://github.com/react/react-native/issues/30974), [#30975](https://github.com/react/react-native/issues/30975), and [#30977](https://github.com/react/react-native/issues/30977).

Screen reader users relying on **Android TalkBack** and **iOS VoiceOver** require explicit collection semantics when navigating virtualized list components (`FlatList`, `SectionList`, `VirtualizedList`). Without these, screen readers treat items as isolated, uncounted views instead of announcing:
- *"In list, N items"* when focusing the list container.
- *"Item X of Y"* when focusing an item.
- *"Showing items X to Y of Z"* when scrolling through list items.

This PR introduces built-in **Accessibility Collection Semantics** and `list` / `listitem` roles for all virtualized lists in React Native.

### Key Implementation Highlights:
1. **View Accessibility Interface (`ViewAccessibility.js`)**:
- Added `'listitem'` to `AccessibilityRole`.
- Added `AccessibilityCollection` and `AccessibilityCollectionItem` Flow types.
- Added `accessibilityCollection`, `accessibilityCollectionItem`, `aria-setsize`, `aria-posinset`, `aria-rowcount`, `aria-colcount` to `AccessibilityProps`.

2. **Item-Level Semantics (`VirtualizedListCellRenderer.js`)**:
- Automatically attaches `accessibilityRole="listitem"`, `aria-setsize={totalCount}`, `aria-posinset={index + 1}`, and `accessibilityCollectionItem` (calculating dynamic `rowIndex` and `columnIndex`).

3. **Container-Level Semantics (`VirtualizedList.js`)**:
- Outer list container automatically receives `accessibilityRole="list"` (or `"grid"` when `numColumns > 1`), `aria-rowcount`, and `accessibilityCollection`.

4. **Multi-Column Grid & Edge-Case Protections**:
- **Grid Support (`numColumns > 1`)**: Dynamically computes row indices and column indices for multi-column `FlatList` grid layouts.
- **Opt-Out Prop (`accessibilityCollectionEnabled`)**: Added `accessibilityCollectionEnabled?: boolean` (defaults to `true`) allowing developers to bypass automatic attributes when needed.
- **Custom Role Preservation**: Respects user-provided `accessibilityRole` overrides.

---

## Changelog:

[GENERAL] [ADDED] - Add Accessibility Collection semantics and list/listitem roles to VirtualizedList, FlatList, and SectionList

---

## Test Plan:

### Automated Tests
- Ran unit test suite in `VirtualizedList-test.js`:
```bash
yarn test packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
```
- Added test case verifying container `accessibilityRole="list"`, `aria-rowcount`, item `accessibilityRole="listitem"`, `aria-setsize`, `aria-posinset`, and `accessibilityCollectionItem`.

### Device & Simulator Verification
- **Android TalkBack**: Verified screen reader announces total count on list focus and item position during navigation.
- **iOS VoiceOver**: Verified VoiceOver reads list container bounds and item indices.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export type AccessibilityRole =
| 'tablist'
| 'timer'
| 'list'
| 'listitem'
| 'toolbar'
| 'grid'
| 'pager'
Expand Down Expand Up @@ -211,6 +212,20 @@ export type AccessibilityValue = Readonly<{
text?: Stringish,
}>;

export type AccessibilityCollection = Readonly<{
rowCount: number,
columnCount: number,
hierarchical: boolean,
}>;

export type AccessibilityCollectionItem = Readonly<{
rowIndex: number,
columnIndex: number,
rowSpan: number,
columnSpan: number,
heading?: boolean,
}>;

export type AccessibilityPropsAndroid = Readonly<{
/**
* Identifies the element that labels the element it is applied to. When the assistive technology focuses on the component with this props,
Expand Down Expand Up @@ -433,4 +448,12 @@ export type AccessibilityProps = Readonly<{
* See https://reactnative.dev/docs/view#aria-hidden
*/
'aria-hidden'?: ?boolean,
accessibilityCollection?: ?AccessibilityCollection,
accessibilityCollectionItem?: ?AccessibilityCollectionItem,
'aria-colcount'?: ?number,
'aria-colindex'?: ?number,
'aria-rowcount'?: ?number,
'aria-rowindex'?: ?number,
'aria-setsize'?: ?number,
'aria-posinset'?: ?number,
}>;
23 changes: 23 additions & 0 deletions packages/virtualized-lists/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,9 @@ class VirtualizedList extends StateSafePureComponent<
cellKey={key}
horizontal={horizontal}
index={ii}
itemCount={itemCount}
numColumns={this.props.numColumns}
accessibilityCollectionEnabled={this.props.accessibilityCollectionEnabled}
inversionStyle={inversionStyle}
item={item}
key={key}
Expand Down Expand Up @@ -1102,7 +1105,27 @@ class VirtualizedList extends StateSafePureComponent<
}

// 4. Render the ScrollView
const a11yEnabled = this.props.accessibilityCollectionEnabled ?? true;
const numCols = this.props.numColumns ?? 1;
const isGrid = numCols > 1;
const rowCount = isGrid ? Math.ceil(itemCount / numCols) : itemCount;
const defaultRole = isGrid ? 'grid' : 'list';

const a11yScrollProps = a11yEnabled
? {
accessibilityRole: this.props.accessibilityRole ?? defaultRole,
'aria-rowcount': rowCount,
...(isGrid && {'aria-colcount': numCols}),
accessibilityCollection: {
rowCount,
columnCount: numCols,
hierarchical: false,
},
}
: {};

const scrollProps = {
...a11yScrollProps,
...this.props,
onContentSizeChange: this._onContentSizeChange,
onLayout: this._onLayout,
Expand Down
33 changes: 33 additions & 0 deletions packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ export type Props<ItemT> = {
cellKey: string,
horizontal: ?boolean,
index: number,
itemCount?: number,
numColumns?: ?number,
accessibilityCollectionEnabled?: ?boolean,
inversionStyle: StyleProp<ViewStyle>,
item: ItemT,
onCellLayout?: (
Expand Down Expand Up @@ -182,6 +185,9 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
horizontal,
item,
index,
itemCount,
numColumns,
accessibilityCollectionEnabled,
inversionStyle,
onCellLayout,
renderItem,
Expand Down Expand Up @@ -211,9 +217,34 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
: horizontal
? [styles.row, inversionStyle]
: inversionStyle;

const a11yEnabled = accessibilityCollectionEnabled ?? true;
const numCols = numColumns ?? 1;
const isGrid = numCols > 1;
const rowIndex = isGrid ? Math.floor(index / numCols) : index;
const columnIndex = isGrid ? index % numCols : 0;

const collectionItem = {
rowIndex,
columnIndex,
rowSpan: 1,
columnSpan: 1,
heading: false,
};

const cellA11yProps = a11yEnabled
? {
accessibilityRole: 'listitem',
'aria-setsize': itemCount,
'aria-posinset': index + 1,
accessibilityCollectionItem: collectionItem,
}
: {};

const result = !CellRendererComponent ? (
<View
style={cellStyle}
{...cellA11yProps}
onFocusCapture={this._onCellFocusCapture}
{...(onCellLayout && {onLayout: this._onLayout})}>
{element}
Expand All @@ -224,7 +255,9 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
cellKey={cellKey}
index={index}
item={item}
itemCount={itemCount}
style={cellStyle}
{...cellA11yProps}
onFocusCapture={this._onCellFocusCapture}
{...(onCellLayout && {onLayout: this._onLayout})}>
{element}
Expand Down
14 changes: 14 additions & 0 deletions packages/virtualized-lists/Lists/VirtualizedListProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ export type CellRendererProps<ItemT> = Readonly<{
children: React.Node,
index: number,
item: ItemT,
itemCount?: number,
accessibilityRole?: string,
'aria-setsize'?: number,
'aria-posinset'?: number,
accessibilityCollectionItem?: Object,
onFocusCapture?: (event: FocusEvent) => void,
onLayout?: (event: LayoutChangeEvent) => void,
style: StyleProp<ViewStyle>,
Expand Down Expand Up @@ -98,6 +103,15 @@ type OptionalVirtualizedListProps = {
...
},
horizontal?: ?boolean,
/**
* When set to false, disables automatic Accessibility Collection and List/ListItem attributes.
* Defaults to true.
*/
accessibilityCollectionEnabled?: ?boolean,
/**
* Multiple columns for grid layout support.
*/
numColumns?: ?number,
/**
* How many items to render in the initial batch. This should be enough to fill the screen but not
* much more. Note these items will never be unmounted as part of the windowed rendering in order
Expand Down
34 changes: 34 additions & 0 deletions packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,40 @@ describe('VirtualizedList', () => {
expect(component).toMatchSnapshot();
});

it('renders accessibility list role and collection item props', async () => {
let component;
await act(() => {
component = create(
<VirtualizedList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
renderItem={({item}) => <item value={item.key} />}
getItem={(data, index) => data[index]}
getItemCount={data => data.length}
/>,
);
});
const root = component.toJSON();
expect(root.props.accessibilityRole).toBe('list');
expect(root.props['aria-rowcount']).toBe(3);
expect(root.props.accessibilityCollection).toEqual({
rowCount: 3,
columnCount: 1,
hierarchical: false,
});
const children = root.children;
expect(children.length).toBe(3);
expect(children[0].props.accessibilityRole).toBe('listitem');
expect(children[0].props['aria-setsize']).toBe(3);
expect(children[0].props['aria-posinset']).toBe(1);
expect(children[0].props.accessibilityCollectionItem).toEqual({
rowIndex: 0,
columnIndex: 0,
rowSpan: 1,
columnSpan: 1,
heading: false,
});
});

it('renders simple list using ListItemComponent', async () => {
function ListItemComponent({item}) {
return <item value={item.key} />;
Expand Down
Loading