diff --git a/PULL_REQUEST.md b/PULL_REQUEST.md new file mode 100644 index 000000000000..eb8ae9c9cdfc --- /dev/null +++ b/PULL_REQUEST.md @@ -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. diff --git a/packages/react-native/Libraries/Components/View/ViewAccessibility.js b/packages/react-native/Libraries/Components/View/ViewAccessibility.js index 369388635674..dc988a6caacd 100644 --- a/packages/react-native/Libraries/Components/View/ViewAccessibility.js +++ b/packages/react-native/Libraries/Components/View/ViewAccessibility.js @@ -44,6 +44,7 @@ export type AccessibilityRole = | 'tablist' | 'timer' | 'list' + | 'listitem' | 'toolbar' | 'grid' | 'pager' @@ -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, @@ -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, }>; diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index b88c980f4755..f2fe66a6a92b 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -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} @@ -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, diff --git a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js index 35573a9d703c..be19c2f3d340 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js +++ b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js @@ -32,6 +32,9 @@ export type Props = { cellKey: string, horizontal: ?boolean, index: number, + itemCount?: number, + numColumns?: ?number, + accessibilityCollectionEnabled?: ?boolean, inversionStyle: StyleProp, item: ItemT, onCellLayout?: ( @@ -182,6 +185,9 @@ export default class CellRenderer extends React.PureComponent< horizontal, item, index, + itemCount, + numColumns, + accessibilityCollectionEnabled, inversionStyle, onCellLayout, renderItem, @@ -211,9 +217,34 @@ export default class CellRenderer 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 ? ( {element} @@ -224,7 +255,9 @@ export default class CellRenderer extends React.PureComponent< cellKey={cellKey} index={index} item={item} + itemCount={itemCount} style={cellStyle} + {...cellA11yProps} onFocusCapture={this._onCellFocusCapture} {...(onCellLayout && {onLayout: this._onLayout})}> {element} diff --git a/packages/virtualized-lists/Lists/VirtualizedListProps.js b/packages/virtualized-lists/Lists/VirtualizedListProps.js index f5675959f853..5a309055f77c 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListProps.js +++ b/packages/virtualized-lists/Lists/VirtualizedListProps.js @@ -44,6 +44,11 @@ export type CellRendererProps = 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, @@ -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 diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index 52b061f7e04b..680df5c5941c 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -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( + } + 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 ;