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
89 changes: 85 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,52 @@ 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,
): {
itemCount: number,
rowCount: number,
columnCount: number,
hierarchical: boolean,
} {
const itemCount = getItemCountForAccessibility(data);
return {
itemCount,
rowCount: numColumns > 1 ? Math.ceil(itemCount / numColumns) : itemCount,
columnCount: numColumns,
hierarchical: false,
};
}

function addAccessibilityCollectionItem(
element: React.Node,
accessibilityCollectionItem: ?$FlowFixMe,
): React.Node {
if (
accessibilityCollectionItem == null ||
!React.isValidElement(element) ||
element.type === React.Fragment
) {
return element;
}

// $FlowFixMe[prop-missing] React.Element internal inspection.
if (element.props.accessibilityCollectionItem != null) {
return element;
}

return React.cloneElement(element, {
accessibilityCollectionItem,
});
}

type FlatListBaseProps<ItemT> = {
...RequiredFlatListProps<ItemT>,
...OptionalFlatListProps<ItemT>,
Expand Down Expand Up @@ -635,28 +681,46 @@ class FlatList<ItemT = any> extends React.PureComponent<FlatListProps<ItemT>> {

const renderProp = (info: ListRenderItemInfo<ItemT>) => {
if (cols > 1) {
const {item, index} = info;
const {accessibilityCollectionItem, item, index} = info;
invariant(
Array.isArray(item),
'Expected array of items with numColumns > 1',
);
return (
<View style={StyleSheet.compose(styles.row, columnWrapperStyle)}>
{item.map((it, kk) => {
const itemIndex = index * cols + kk;
const itemAccessibilityCollectionItem =
accessibilityCollectionItem == null
? undefined
: {
...accessibilityCollectionItem,
columnIndex: kk,
itemIndex,
};
const element = render({
// $FlowFixMe[incompatible-type]
item: it,
index: index * cols + kk,
index: itemIndex,
separators: info.separators,
accessibilityCollectionItem: itemAccessibilityCollectionItem,
});
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);
return addAccessibilityCollectionItem(
render(info),
info.accessibilityCollectionItem,
);
}
};

Expand All @@ -677,11 +741,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),
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
136 changes: 136 additions & 0 deletions packages/react-native/Libraries/Lists/__tests__/FlatList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
'use strict';

const FlatList = require('../FlatList').default;
const Platform = require('../../Utilities/Platform').default;
const {create} = require('@react-native/jest-preset/jest/renderer');
const React = require('react');
const {createRef} = require('react');
Expand All @@ -35,6 +36,141 @@ describe('FlatList', () => {
);
expect(component).toMatchSnapshot();
});
it('adds Android accessibility collection metadata to list items', async () => {
const originalOS = Platform.OS;
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
Platform.OS = 'android';

try {
const component = await create(
<FlatList
data={[{key: 'i1'}, {key: 'i2'}, {key: 'i3'}]}
renderItem={({item}) => <item value={item.key} />}
/>,
);

const root = component.toJSON();
expect(root?.props.accessibilityRole).toBe('list');
expect(root?.props.accessibilityCollection).toEqual({
itemCount: 3,
rowCount: 3,
columnCount: 1,
hierarchical: false,
});
expect(
component.root
.findAllByType('item')
.map(item => item.props.accessibilityCollectionItem),
).toEqual([
{
itemIndex: 0,
rowIndex: 0,
rowSpan: 1,
columnIndex: 0,
columnSpan: 1,
heading: false,
},
{
itemIndex: 1,
rowIndex: 1,
rowSpan: 1,
columnIndex: 0,
columnSpan: 1,
heading: false,
},
{
itemIndex: 2,
rowIndex: 2,
rowSpan: 1,
columnIndex: 0,
columnSpan: 1,
heading: false,
},
]);
} finally {
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
Platform.OS = originalOS;
}
});
it('adds Android accessibility collection metadata to multi-column list items', async () => {
const originalOS = Platform.OS;
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
Platform.OS = 'android';

try {
const component = await create(
<FlatList
data={[
{key: 'i1'},
{key: 'i2'},
{key: 'i3'},
{key: 'i4'},
{key: 'i5'},
]}
renderItem={({item}) => <item value={item.key} />}
numColumns={2}
/>,
);

const root = component.toJSON();
expect(root?.props.accessibilityRole).toBe('grid');
expect(root?.props.accessibilityCollection).toEqual({
itemCount: 5,
rowCount: 3,
columnCount: 2,
hierarchical: false,
});
expect(
component.root
.findAllByType('item')
.map(item => item.props.accessibilityCollectionItem),
).toEqual([
{
itemIndex: 0,
rowIndex: 0,
rowSpan: 1,
columnIndex: 0,
columnSpan: 1,
heading: false,
},
{
itemIndex: 1,
rowIndex: 0,
rowSpan: 1,
columnIndex: 1,
columnSpan: 1,
heading: false,
},
{
itemIndex: 2,
rowIndex: 1,
rowSpan: 1,
columnIndex: 0,
columnSpan: 1,
heading: false,
},
{
itemIndex: 3,
rowIndex: 1,
rowSpan: 1,
columnIndex: 1,
columnSpan: 1,
heading: false,
},
{
itemIndex: 4,
rowIndex: 2,
rowSpan: 1,
columnIndex: 0,
columnSpan: 1,
heading: false,
},
]);
} finally {
// $FlowFixMe[incompatible-type] Platform.OS is read-only in production.
Platform.OS = originalOS;
}
});
it('renders simple list using ListItemComponent', async () => {
function ListItemComponent({item}: Readonly<{item: {key: string}}>) {
return <item value={item.key} />;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,33 +70,13 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
} else {
return
}
var accessibilityCollectionItem: ReadableMap? =
nextChild.getTag(R.id.accessibility_collection_item) as ReadableMap
val accessibilityCollectionItemRange = findAccessibilityCollectionItemRange(nextChild)

if (nextChild !is ViewGroup) {
return
}

// If this child's accessibilityCollectionItem is null, we'll check one more
// nested child.
// Happens when getItemLayout is not passed in FlatList which adds an additional
// View in the hierarchy.
if (nextChild.childCount > 0 && accessibilityCollectionItem == null) {
val nestedNextChild = nextChild.getChildAt(0)
if (nestedNextChild != null) {
val nestedChildAccessibility =
nestedNextChild.getTag(R.id.accessibility_collection_item) as? ReadableMap
if (nestedChildAccessibility != null) {
accessibilityCollectionItem = nestedChildAccessibility
}
}
}

if (isVisible && accessibilityCollectionItem != null) {
if (isVisible && accessibilityCollectionItemRange != null) {
if (firstVisibleIndex == null) {
firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
firstVisibleIndex = accessibilityCollectionItemRange.first
}
lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex")
lastVisibleIndex = accessibilityCollectionItemRange.second
}

if (firstVisibleIndex != null && lastVisibleIndex != null) {
Expand All @@ -106,6 +86,36 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa
}
}

private fun findAccessibilityCollectionItemRange(view: View): Pair<Int, Int>? {
val accessibilityCollectionItem =
view.getTag(R.id.accessibility_collection_item) as? ReadableMap
if (accessibilityCollectionItem != null) {
val itemIndex = accessibilityCollectionItem.getInt("itemIndex")
return Pair(itemIndex, itemIndex)
}

if (view !is ViewGroup) {
return null
}

var firstItemIndex: Int? = null
var lastItemIndex: Int? = null
for (index in 0..<view.childCount) {
val childItemRange =
findAccessibilityCollectionItemRange(view.getChildAt(index)) ?: continue
if (firstItemIndex == null) {
firstItemIndex = childItemRange.first
}
lastItemIndex = childItemRange.second
}

return if (firstItemIndex != null && lastItemIndex != null) {
Pair(firstItemIndex, lastItemIndex)
} else {
null
}
}

private fun onInitializeAccessibilityNodeInfoInternal(
view: View,
info: AccessibilityNodeInfoCompat,
Expand Down
Loading