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
139 changes: 135 additions & 4 deletions packages/react-native/Libraries/Lists/FlatList.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,88 @@ function isArrayLike(data: unknown): boolean {
return typeof Object(data).length === 'number';
}

function getItemCountForAccessibility<ItemT>(
data: ?Readonly<$ArrayLike<ItemT>>,
): number {
return data != null && isArrayLike(data) ? data.length : 0;
}

function createAccessibilityCollection<ItemT>(
data: ?Readonly<$ArrayLike<ItemT>>,
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<ItemT> = {
...RequiredFlatListProps<ItemT>,
...OptionalFlatListProps<ItemT>,
Expand Down Expand Up @@ -634,29 +716,61 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {
};

const renderProp = (info: ListRenderItemInfo<ItemT>) => {
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 (
<View style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
<View
{...rowAccessibilityProps}
style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
Comment on lines +727 to +733
{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 ? (
<React.Fragment key={kk}>{element}</React.Fragment>
<React.Fragment key={kk}>
{addAccessibilityCollectionItem(
element,
itemAccessibilityCollectionItem,
)}
</React.Fragment>
) : null;
})}
</View>
);
} else {
return render(info);
const itemAccessibilityCollectionItem = isAndroid
? createAccessibilityCollectionItem(
info.index,
info.index,
0,
isHorizontal,
)
: undefined;
return addAccessibilityCollectionItem(
render(info),
itemAccessibilityCollectionItem,
);
}
};

Expand All @@ -677,11 +791,28 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {
} = 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.
<VirtualizedList
{...restProps}
{...androidAccessibilityProps}
getItem={this._getItem}
getItemCount={this._getItemCount}
keyExtractor={this._keyExtractor}
Expand Down
127 changes: 126 additions & 1 deletion packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,38 @@ import * as Fantom from '@react-native/fantom';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {createRef} from 'react';
import {FlatList, Text, View} from 'react-native';
import {FlatList, Platform, Text, View} from 'react-native';
import ReactNativeElement from 'react-native/src/private/webapis/dom/nodes/ReactNativeElement';

// Object-valued props are serialized to a JSON string by the native Fantom
// renderer, so we defensively parse them back into objects.
// $FlowFixMe[unclear-type] parseCollectionProp returns a loosely-typed object.
function parseCollectionProp(value: unknown): any {
if (typeof value === 'string') {
return JSON.parse(value);
}
return value;
}

// $FlowFixMe[unclear-type] collectItems walks a dynamically-typed tree.
function collectItems(node: unknown, acc: Array<any> = []): Array<any> {
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<TValue>({
propName,
value,
Expand Down Expand Up @@ -102,6 +131,102 @@ describe('<FlatList>', () => {
});
});

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 {
Comment on lines +134 to +138
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<FlatList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
renderItem={({item}) => <Text>{item.key}</Text>}
/>,
);
});

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 {
Comment on lines +178 to +182
const root = Fantom.createRoot();
Fantom.runTask(() => {
root.render(
<FlatList
data={[
{key: 'i1'},
{key: 'i2'},
{key: 'i3'},
{key: 'i4'},
{key: 'i5'},
]}
renderItem={({item}) => <Text>{item.key}</Text>}
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();
Expand Down