diff --git a/.changeset/great-zebras-tease.md b/.changeset/great-zebras-tease.md new file mode 100644 index 000000000000..0a08d617a55f --- /dev/null +++ b/.changeset/great-zebras-tease.md @@ -0,0 +1,7 @@ +--- +"@react-native-macos/virtualized-lists": patch +"react-native-macos": patch +--- + +Sync the virtualized-lists fork with upstream and keep macOS selection in FlatList + diff --git a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js index cb141d793057..0dcd8e09918b 100644 --- a/packages/react-native/Libraries/Components/ScrollView/ScrollView.js +++ b/packages/react-native/Libraries/Components/ScrollView/ScrollView.js @@ -1744,7 +1744,8 @@ class ScrollView extends React.Component { // this is the case. const preserveChildren = this.props.maintainVisibleContentPosition != null || - (Platform.OS === 'android' && this.props.snapToAlignment != null); + (Platform.OS === 'android' && this.props.snapToAlignment != null) || + (Platform.OS === 'macos' && this.props.inverted === true); // [macOS] const contentContainer = ( { ); }); + it('preserves children for native inversion', async () => { + jest.dontMock('../ScrollView'); + + const component = await create( + + + , + ); + const nativeScrollView = component.root.findByType('RCTScrollView'); + const contentView = component.root.findByType('RCTScrollContentView'); + + expect(nativeScrollView.props.inverted).toBe(true); + expect(contentView.props.inverted).toBe(true); + expect(contentView.props.collapsableChildren).toBe(Platform.OS !== 'macos'); + }); + it('mocks native methods and instance methods', async () => { jest.mock('../ScrollView'); diff --git a/packages/react-native/Libraries/Lists/FlatList.d.ts b/packages/react-native/Libraries/Lists/FlatList.d.ts index 045c60fb8055..fc77c4d61e1a 100644 --- a/packages/react-native/Libraries/Lists/FlatList.d.ts +++ b/packages/react-native/Libraries/Lists/FlatList.d.ts @@ -9,7 +9,7 @@ import type * as React from 'react'; import type { - ListRenderItem, + ListRenderItemInfo, ViewToken, VirtualizedListProps, ViewabilityConfig, @@ -19,7 +19,20 @@ import type {StyleProp} from '../StyleSheet/StyleSheet'; import type {ViewStyle} from '../StyleSheet/StyleSheetTypes'; import type {View} from '../Components/View/View'; -export interface FlatListProps extends VirtualizedListProps { +export interface FlatListRenderItemInfo + extends ListRenderItemInfo { + isSelected: boolean; +} + +export type FlatListRenderItem = ( + info: FlatListRenderItemInfo, +) => React.ReactNode; + +export interface FlatListProps + extends Omit< + VirtualizedListProps, + 'ListItemComponent' | 'renderItem' + > { /** * Optional custom style for multi-item rows generated when numColumns > 1 */ @@ -87,6 +100,44 @@ export interface FlatListProps extends VirtualizedListProps { */ initialScrollIndex?: number | null | undefined; + /** + * Allows rows to be selected with the keyboard. + * + * @platform macos + */ + enableSelectionOnKeyPress?: boolean | null | undefined; + + /** + * The initially selected row. + * + * @platform macos + */ + initialSelectedIndex?: number | null | undefined; + + /** + * Called when the selected row changes. + * + * @platform macos + */ + onSelectionChanged?: + | ((info: { + previousSelection: number; + newSelection: number; + item: ItemT | ReadonlyArray | null | undefined; + }) => void) + | null + | undefined; + + /** + * Called when Enter is pressed on the selected row. + * + * @platform macos + */ + onSelectionEntered?: + | ((item: ItemT | ReadonlyArray | null | undefined) => void) + | null + | undefined; + /** * Used to extract a unique key for a given item at the specified index. Key is used for caching * and as the react key to track item re-ordering. The default extractor checks `item.key`, then @@ -140,7 +191,13 @@ export interface FlatListProps extends VirtualizedListProps { * ``` * Provides additional metadata like `index` if you need it. */ - renderItem: ListRenderItem | null | undefined; + renderItem: FlatListRenderItem | null | undefined; + + ListItemComponent?: + | React.ComponentType> + | React.ReactElement + | null + | undefined; /** * See `ViewabilityHelper` for flow type and further documentation. @@ -223,6 +280,13 @@ export abstract class FlatListComponent< */ flashScrollIndicators: () => void; + /** + * Moves selection to the specified row. + * + * @platform macos + */ + selectRowAtIndex: (index: number) => void; + /** * Provides a handle to the underlying scroll responder. */ diff --git a/packages/react-native/Libraries/Lists/FlatList.js b/packages/react-native/Libraries/Lists/FlatList.js index 68f87481187a..0dbb2b4d89c5 100644 --- a/packages/react-native/Libraries/Lists/FlatList.js +++ b/packages/react-native/Libraries/Lists/FlatList.js @@ -10,8 +10,8 @@ import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent'; import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; +import type {HandledKeyEvent, KeyEvent} from '../Types/CoreEventTypes'; import type { - ListRenderItem, ListRenderItemInfo, ViewabilityConfigCallbackPair, ViewToken, @@ -40,6 +40,17 @@ type RequiredFlatListProps = { */ data: ?$ReadOnly<$ArrayLike>, }; + +export type FlatListRenderItemInfo = { + ...ListRenderItemInfo, + isSelected: boolean, + ... +}; + +export type FlatListRenderItem = ( + info: FlatListRenderItemInfo, +) => React.Node; + type OptionalFlatListProps = { /** * Takes an item from `data` and renders it into the list. Example usage: @@ -67,7 +78,7 @@ type OptionalFlatListProps = { * `highlight` and `unhighlight` (which set the `highlighted: boolean` prop) are insufficient for * your use-case. */ - renderItem?: ?ListRenderItem, + renderItem?: ?FlatListRenderItem, /** * Optional custom style for multi-item rows generated when numColumns > 1. @@ -90,6 +101,23 @@ type OptionalFlatListProps = { * @platform macos */ enableSelectionOnKeyPress?: ?boolean, + /** + * Called when the selected row changes. + * + * @platform macos + */ + onSelectionChanged?: ?(info: { + previousSelection: number, + newSelection: number, + item: ?(ItemT | $ReadOnlyArray), + ... + }) => void, + /** + * Called when Enter is pressed on the selected row. + * + * @platform macos + */ + onSelectionEntered?: ?(item: ?(ItemT | $ReadOnlyArray)) => void, // macOS] /** * A marker property for telling the list to re-render (since it implements `PureComponent`). If @@ -143,7 +171,7 @@ type OptionalFlatListProps = { initialSelectedIndex?: ?number, // macOS] /** - * Reverses the direction of scroll. Uses native inversion on macOS and scale transforms of -1 elsewhere + * Reverses the direction of scroll. Uses native inversion on macOS and scale transforms of -1 elsewhere. */ inverted?: ?boolean, /** @@ -201,6 +229,16 @@ function isArrayLike(data: mixed): boolean { return typeof Object(data).length === 'number'; } +const SELECTION_KEY_DOWN_EVENTS: $ReadOnlyArray = [ + {key: 'ArrowUp'}, + {key: 'ArrowUp', altKey: true}, + {key: 'ArrowDown'}, + {key: 'ArrowDown', altKey: true}, + {key: 'Home'}, + {key: 'End'}, + {key: 'Enter'}, +]; + type FlatListBaseProps = { ...RequiredFlatListProps, ...OptionalFlatListProps, @@ -220,6 +258,10 @@ export type FlatListProps = { ... }; +type FlatListState = { + selectedRowIndex: number, +}; + /** * A performant interface for rendering simple, flat lists, supporting the most handy features: * @@ -328,7 +370,10 @@ export type FlatListProps = { * * Also inherits [ScrollView Props](docs/scrollview.html#props), unless it is nested in another FlatList of same orientation. */ -class FlatList extends React.PureComponent> { +class FlatList extends React.PureComponent< + FlatListProps, + FlatListState, +> { /** * Scrolls to the end of the content. May be janky without `getItemLayout` prop. */ @@ -416,9 +461,7 @@ class FlatList extends React.PureComponent> { * @platform macos */ selectRowAtIndex(index: number) { - if (this._listRef) { - this._listRef.selectRowAtIndex(index); - } + this._selectRowAtIndex(index); } // macOS] @@ -458,6 +501,9 @@ class FlatList extends React.PureComponent> { constructor(props: FlatListProps) { super(props); + this.state = { + selectedRowIndex: props.initialSelectedIndex ?? -1, + }; this._checkProps(this.props); if (this.props.viewabilityConfigCallbackPairs) { this._virtualizedListPairs = @@ -512,15 +558,113 @@ class FlatList extends React.PureComponent> { ); this._checkProps(this.props); + + const itemCount = this._getItemCount(this.props.data); + if (this.state.selectedRowIndex >= itemCount) { + this.setState({selectedRowIndex: itemCount - 1}); + } } _listRef: ?VirtualizedList; + _selectionScrollPending: boolean = false; _virtualizedListPairs: Array = []; _captureRef = (ref: ?VirtualizedList) => { this._listRef = ref; }; + _selectRowAtIndex = (index: number) => { + const itemCount = this._getItemCount(this.props.data); + invariant( + index >= 0 && index < itemCount, + `selectRowAtIndex out of range: requested index ${index} is out of 0 to ${ + itemCount - 1 + }`, + ); + + const previousSelection = this.state.selectedRowIndex; + this.setState({selectedRowIndex: index}); + this._selectionScrollPending = true; + try { + this._listRef?.scrollToIndex({animated: false, index}); + } finally { + this._selectionScrollPending = false; + } + + if (previousSelection !== index) { + this.props.onSelectionChanged?.({ + previousSelection, + newSelection: index, + item: this._getItem((this.props.data: any), index), + }); + } + }; + + _handleSelectionKeyDown = (event: KeyEvent) => { + this.props.onKeyDown?.(event); + if (event.defaultPrevented || event.isDefaultPrevented()) { + return; + } + + const itemCount = this._getItemCount(this.props.data); + const selectedRowIndex = this.state.selectedRowIndex; + + switch (event.nativeEvent.key) { + case 'ArrowUp': + if (itemCount > 0) { + this._selectRowAtIndex( + event.nativeEvent.altKey + ? 0 + : Math.max(0, selectedRowIndex < 0 ? 0 : selectedRowIndex - 1), + ); + } + break; + case 'ArrowDown': + if (itemCount > 0) { + this._selectRowAtIndex( + event.nativeEvent.altKey + ? itemCount - 1 + : Math.min(itemCount - 1, selectedRowIndex + 1), + ); + } + break; + case 'Enter': + if (selectedRowIndex >= 0 && selectedRowIndex < itemCount) { + this.props.onSelectionEntered?.( + this._getItem((this.props.data: any), selectedRowIndex), + ); + } + break; + case 'Home': + this.scrollToOffset({animated: true, offset: 0}); + break; + case 'End': + this.scrollToEnd({animated: true}); + break; + } + }; + + _handleSelectionScrollToIndexFailed = (info: { + index: number, + highestMeasuredFrameIndex: number, + averageItemLength: number, + ... + }) => { + if (this.props.onScrollToIndexFailed) { + this.props.onScrollToIndexFailed(info); + } else { + invariant( + this._selectionScrollPending, + 'scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed, ' + + 'otherwise there is no way to know the location of offscreen indices or handle failures.', + ); + this.scrollToOffset({ + animated: false, + offset: info.averageItemLength * info.index, + }); + } + }; + // $FlowFixMe[missing-local-annot] _checkProps(props: FlatListProps) { const { @@ -652,15 +796,17 @@ class FlatList extends React.PureComponent> { _renderer = ( ListItemComponent: ?(React.ComponentType | React.MixedElement), - renderItem: ?ListRenderItem, + renderItem: ?FlatListRenderItem, columnWrapperStyle: ?ViewStyleProp, numColumns: ?number, extraData: ?any, + enableSelectionOnKeyPress: boolean, + selectedRowIndex: number, // $FlowFixMe[missing-local-annot] ) => { const cols = numColumnsOrDefault(numColumns); - const render = (props: ListRenderItemInfo): React.Node => { + const render = (props: FlatListRenderItemInfo): React.Node => { if (ListItemComponent) { // $FlowFixMe[not-a-component] Component isn't valid // $FlowFixMe[incompatible-type-arg] Component isn't valid @@ -675,6 +821,8 @@ class FlatList extends React.PureComponent> { }; const renderProp = (info: ListRenderItemInfo) => { + const isSelected = + enableSelectionOnKeyPress && selectedRowIndex === info.index; if (cols > 1) { const {item, index} = info; invariant( @@ -688,7 +836,7 @@ class FlatList extends React.PureComponent> { // $FlowFixMe[incompatible-call] item: it, index: index * cols + kk, - isSelected: info.isSelected, // [macOS] + isSelected, separators: info.separators, }); return element != null ? ( @@ -698,7 +846,7 @@ class FlatList extends React.PureComponent> { ); } else { - return render(info); + return render({...info, isSelected}); } }; @@ -715,10 +863,22 @@ class FlatList extends React.PureComponent> { columnWrapperStyle, removeClippedSubviews: _removeClippedSubviews, strictMode = false, + enableSelectionOnKeyPress = false, + focusable, + initialSelectedIndex: _initialSelectedIndex, + keyDownEvents, + onKeyDown, + onScrollToIndexFailed: _onScrollToIndexFailed, + onSelectionChanged: _onSelectionChanged, + onSelectionEntered: _onSelectionEntered, ...restProps } = this.props; const renderer = strictMode ? this._memoizedRenderer : this._renderer; + const selectionEnabled = enableSelectionOnKeyPress === true; + const selectionKeyDownEvents = selectionEnabled + ? [...SELECTION_KEY_DOWN_EVENTS, ...(keyDownEvents ?? [])] + : keyDownEvents; return ( // $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact. @@ -729,6 +889,10 @@ class FlatList extends React.PureComponent> { keyExtractor={this._keyExtractor} ref={this._captureRef} viewabilityConfigCallbackPairs={this._virtualizedListPairs} + focusable={selectionEnabled ? focusable ?? true : focusable} + keyDownEvents={selectionKeyDownEvents} + onKeyDown={selectionEnabled ? this._handleSelectionKeyDown : onKeyDown} + onScrollToIndexFailed={this._handleSelectionScrollToIndexFailed} removeClippedSubviews={removeClippedSubviewsOrDefault( _removeClippedSubviews, )} @@ -738,6 +902,8 @@ class FlatList extends React.PureComponent> { columnWrapperStyle, numColumns, this.props.extraData, + selectionEnabled, + this.state.selectedRowIndex, )} /> ); diff --git a/packages/react-native/Libraries/Lists/__tests__/FlatList-test.js b/packages/react-native/Libraries/Lists/__tests__/FlatList-test.js index cf4c1a232b60..fe8f62eec819 100644 --- a/packages/react-native/Libraries/Lists/__tests__/FlatList-test.js +++ b/packages/react-native/Libraries/Lists/__tests__/FlatList-test.js @@ -11,9 +11,23 @@ 'use strict'; const {create} = require('../../../jest/renderer'); +const ScrollView = require('../../Components/ScrollView/ScrollView').default; const FlatList = require('../FlatList').default; const React = require('react'); const {createRef} = require('react'); +const {act} = require('react-test-renderer'); + +function createKeyEvent(key: string, altKey: boolean = false) { + let defaultPrevented = false; + return { + defaultPrevented, + isDefaultPrevented: () => defaultPrevented, + nativeEvent: {altKey, key}, + preventDefault: () => { + defaultPrevented = true; + }, + }; +} describe('FlatList', () => { it('renders simple list', async () => { @@ -60,6 +74,127 @@ describe('FlatList', () => { ); expect(component).toMatchSnapshot(); }); + + it('owns keyboard row selection state', async () => { + const listRef = createRef>(); + const onSelectionChanged = jest.fn(); + const onSelectionEntered = jest.fn(); + const component = await create( + ( + + )} + />, + ); + + expect( + component.root.findAllByType('item').map(item => item.props.isSelected), + ).toEqual([true, false]); + + const scrollView = component.root.findByType(ScrollView); + expect(scrollView.props.keyDownEvents).toContainEqual({key: 'Enter'}); + + await act(async () => { + scrollView.props.onKeyDown(createKeyEvent('ArrowDown')); + }); + + expect( + component.root.findAllByType('item').map(item => item.props.isSelected), + ).toEqual([false, true]); + expect(onSelectionChanged).toHaveBeenCalledWith({ + item: {key: 'i2'}, + newSelection: 1, + previousSelection: 0, + }); + + scrollView.props.onKeyDown(createKeyEvent('Enter')); + expect(onSelectionEntered).toHaveBeenCalledWith({key: 'i2'}); + + await act(async () => { + listRef.current?.selectRowAtIndex(0); + }); + expect(onSelectionChanged).toHaveBeenLastCalledWith({ + item: {key: 'i1'}, + newSelection: 0, + previousSelection: 1, + }); + }); + + it('lets consumers prevent keyboard selection', async () => { + const onKeyDown = jest.fn(event => event.preventDefault()); + const onSelectionChanged = jest.fn(); + const component = await create( + } + />, + ); + + component.root + .findByType(ScrollView) + .props.onKeyDown(createKeyEvent('ArrowDown')); + + expect(onKeyDown).toHaveBeenCalledTimes(1); + expect(onSelectionChanged).not.toHaveBeenCalled(); + }); + + it('rejects selecting a row outside the data', async () => { + const listRef = createRef>(); + await create( + } + />, + ); + + expect(() => listRef.current?.selectRowAtIndex(1)).toThrow( + 'selectRowAtIndex out of range', + ); + }); + + it('selects unmeasured rows without changing scrollToIndex failures', async () => { + const listRef = createRef>(); + const data: Array<{key: string}> = Array.from( + {length: 100}, + (_, index) => ({key: `item-${index}`}), + ); + await create( + } + />, + ); + const list = listRef.current; + if (list == null) { + throw new Error('FlatList ref was not set'); + } + const scrollToOffset = jest.spyOn(list, 'scrollToOffset'); + + await act(async () => { + list.selectRowAtIndex(50); + }); + + expect(scrollToOffset).toHaveBeenCalledWith({ + animated: false, + offset: expect.any(Number), + }); + expect(() => list.scrollToIndex({index: 50})).toThrow( + 'scrollToIndex should be used in conjunction with getItemLayout', + ); + }); it('renders empty list', async () => { const component = await create( } />, diff --git a/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap index 4912d50a2cc2..cb36f06f114b 100644 --- a/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap +++ b/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap @@ -21,6 +21,7 @@ exports[`FlatList ignores invalid data 1`] = ` onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} onScrollShouldSetResponder={[Function]} + onScrollToIndexFailed={[Function]} onStartShouldSetResponder={[Function]} onStartShouldSetResponderCapture={[Function]} onTouchCancel={[Function]} @@ -98,6 +99,7 @@ exports[`FlatList renders all the bells and whistles 1`] = ` onScroll={[Function]} onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} + onScrollToIndexFailed={[Function]} refreshControl={