diff --git a/packages/react-native/Libraries/Lists/FlatList.js b/packages/react-native/Libraries/Lists/FlatList.js index ff4563be2406..9b7564cecefa 100644 --- a/packages/react-native/Libraries/Lists/FlatList.js +++ b/packages/react-native/Libraries/Lists/FlatList.js @@ -177,6 +177,88 @@ function isArrayLike(data: unknown): boolean { return typeof Object(data).length === 'number'; } +function getItemCountForAccessibility( + data: ?Readonly<$ArrayLike>, +): number { + return data != null && isArrayLike(data) ? data.length : 0; +} + +function createAccessibilityCollection( + data: ?Readonly<$ArrayLike>, + numColumns: number, + horizontal: boolean, +): { + itemCount: number, + rowCount: number, + columnCount: number, + hierarchical: boolean, +} { + const itemCount = getItemCountForAccessibility(data); + const totalCols = numColumns > 1 ? numColumns : 1; + if (horizontal) { + return { + itemCount, + rowCount: totalCols, + columnCount: Math.ceil(itemCount / totalCols), + hierarchical: false, + }; + } + return { + itemCount, + rowCount: Math.ceil(itemCount / totalCols), + columnCount: totalCols, + hierarchical: false, + }; +} + +type AccessibilityCollectionItem = Readonly<{ + itemIndex: number, + rowIndex: number, + rowSpan: number, + columnIndex: number, + columnSpan: number, + heading: boolean, + ... +}>; + +function createAccessibilityCollectionItem( + itemIndex: number, + rowIndex: number, + columnIndex: number, + horizontal: boolean, +): AccessibilityCollectionItem { + return { + itemIndex, + rowIndex: horizontal ? 0 : rowIndex, + rowSpan: 1, + columnIndex: horizontal ? itemIndex : columnIndex, + columnSpan: 1, + heading: false, + }; +} + +function addAccessibilityCollectionItem( + element: React.Node, + accessibilityCollectionItem: ?AccessibilityCollectionItem, +): React.Node { + const elementForAccessibility: any = element; + if ( + accessibilityCollectionItem == null || + !React.isValidElement(elementForAccessibility) || + elementForAccessibility.type === React.Fragment + ) { + return element; + } + + if (elementForAccessibility.props.accessibilityCollectionItem !== undefined) { + return element; + } + + return React.cloneElement(elementForAccessibility, { + accessibilityCollectionItem, + }); +} + type FlatListBaseProps = { ...RequiredFlatListProps, ...OptionalFlatListProps, @@ -634,29 +716,61 @@ class FlatList extends React.PureComponent> { }; const renderProp = (info: ListRenderItemInfo) => { + const isAndroid = Platform.OS === 'android'; + const isHorizontal = this.props.horizontal === true; if (cols > 1) { const {item, index} = info; invariant( Array.isArray(item), 'Expected array of items with numColumns > 1', ); + const rowAccessibilityProps: any = isAndroid + ? ({}: any) + : {}; return ( - + {item.map((it, kk) => { + const itemIndex = index * cols + kk; + const itemAccessibilityCollectionItem = isAndroid + ? createAccessibilityCollectionItem( + itemIndex, + index, + kk, + isHorizontal, + ) + : undefined; const element = render({ // $FlowFixMe[incompatible-type] item: it, - index: index * cols + kk, + index: itemIndex, separators: info.separators, }); return element != null ? ( - {element} + + {addAccessibilityCollectionItem( + element, + itemAccessibilityCollectionItem, + )} + ) : null; })} ); } else { - return render(info); + const itemAccessibilityCollectionItem = isAndroid + ? createAccessibilityCollectionItem( + info.index, + info.index, + 0, + isHorizontal, + ) + : undefined; + return addAccessibilityCollectionItem( + render(info), + itemAccessibilityCollectionItem, + ); } }; @@ -677,11 +791,28 @@ class FlatList extends React.PureComponent> { } = this.props; const renderer = strictMode ? this._memoizedRenderer : this._renderer; + const numColumnsValue = numColumnsOrDefault(numColumns); + const androidAccessibilityProps = + Platform.OS === 'android' + ? { + accessibilityCollection: + // $FlowFixMe[prop-missing] Internal native prop. + this.props.accessibilityCollection ?? + createAccessibilityCollection(this.props.data, numColumnsValue, this.props.horizontal === true), + accessibilityRole: + this.props.role == null && this.props.accessibilityRole == null + ? numColumnsValue > 1 + ? 'grid' + : 'list' + : this.props.accessibilityRole, + } + : {}; return ( // $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact. = []): Array { + if (node == null || typeof node === 'string') { + return acc; + } + // $FlowFixMe[unclear-type] Fantom nodes have a dynamic shape. + const n: any = node; + const item = n.props != null ? n.props.accessibilityCollectionItem : null; + if (item != null) { + acc.push(parseCollectionProp(item)); + } + if (Array.isArray(n.children)) { + n.children.forEach(child => { + collectItems(child, acc); + }); + } + return acc; +} + function testPropPropagatedToMountingLayer({ propName, value, @@ -102,6 +131,102 @@ describe('', () => { }); }); + it('adds Android accessibility collection metadata to list items', () => { + const originalPlatform = Platform.OS; + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = 'android'; + try { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {item.key}} + />, + ); + }); + + const list = root + .getRenderedOutput({ + props: ['accessibilityRole', 'accessibilityCollection'], + }) + .toJSONObject(); + expect(list.props.accessibilityRole).toBe('list'); + expect(parseCollectionProp(list.props.accessibilityCollection)).toEqual( + { + itemCount: 3, + rowCount: 3, + columnCount: 1, + hierarchical: false, + }, + ); + + const items = collectItems( + root + .getRenderedOutput({props: ['accessibilityCollectionItem']}) + .toJSONObject(), + ); + expect(items.map(i => i.itemIndex).sort((a, b) => a - b)).toEqual([ + 0, 1, 2, + ]); + } finally { + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = originalPlatform; + } + }); + + it('adds Android accessibility collection metadata to multi-column list items', () => { + const originalPlatform = Platform.OS; + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = 'android'; + try { + const root = Fantom.createRoot(); + Fantom.runTask(() => { + root.render( + {item.key}} + numColumns={2} + />, + ); + }); + + const list = root + .getRenderedOutput({ + props: ['accessibilityRole', 'accessibilityCollection'], + }) + .toJSONObject(); + expect(list.props.accessibilityRole).toBe('grid'); + expect(parseCollectionProp(list.props.accessibilityCollection)).toEqual( + { + itemCount: 5, + rowCount: 3, + columnCount: 2, + hierarchical: false, + }, + ); + + const items = collectItems( + root + .getRenderedOutput({props: ['accessibilityCollectionItem']}) + .toJSONObject(), + ); + expect(items.map(i => i.itemIndex).sort((a, b) => a - b)).toEqual([ + 0, 1, 2, 3, 4, + ]); + expect(items.some(i => i.columnIndex === 1)).toBe(true); + } finally { + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = originalPlatform; + } + }); + describe('inverted', () => { it('changes prop isInvertedVirtualizedList which gets propagated to mounting layer', () => { const root = Fantom.createRoot();