diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index a9532d681..5efec1875 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -35,12 +35,19 @@ test('resetToDefaults() resets config to defaults', () => { test('resetToDefaults() resets internal config to defaults', () => { configureInternal({ - hostComponentNames: { text: 'A', textInput: 'A', switch: 'A', modal: 'A' }, + hostComponentNames: { + text: 'A', + textInput: 'A', + switch: 'A', + scrollView: 'A', + modal: 'A', + }, }); expect(getConfig().hostComponentNames).toEqual({ text: 'A', textInput: 'A', switch: 'A', + scrollView: 'A', modal: 'A', }); diff --git a/src/__tests__/host-component-names.test.tsx b/src/__tests__/host-component-names.test.tsx index 881663d87..48d5d4deb 100644 --- a/src/__tests__/host-component-names.test.tsx +++ b/src/__tests__/host-component-names.test.tsx @@ -21,6 +21,7 @@ describe('getHostComponentNames', () => { text: 'banana', textInput: 'banana', switch: 'banana', + scrollView: 'banana', modal: 'banana', }, }); @@ -29,6 +30,7 @@ describe('getHostComponentNames', () => { text: 'banana', textInput: 'banana', switch: 'banana', + scrollView: 'banana', modal: 'banana', }); }); @@ -42,6 +44,7 @@ describe('getHostComponentNames', () => { text: 'Text', textInput: 'TextInput', switch: 'RCTSwitch', + scrollView: 'RCTScrollView', modal: 'Modal', }); expect(getConfig().hostComponentNames).toBe(hostComponentNames); @@ -71,6 +74,7 @@ describe('configureHostComponentNamesIfNeeded', () => { text: 'Text', textInput: 'TextInput', switch: 'RCTSwitch', + scrollView: 'RCTScrollView', modal: 'Modal', }); }); @@ -81,6 +85,7 @@ describe('configureHostComponentNamesIfNeeded', () => { text: 'banana', textInput: 'banana', switch: 'banana', + scrollView: 'banana', modal: 'banana', }, }); @@ -91,6 +96,7 @@ describe('configureHostComponentNamesIfNeeded', () => { text: 'banana', textInput: 'banana', switch: 'banana', + scrollView: 'banana', modal: 'banana', }); }); diff --git a/src/config.ts b/src/config.ts index 15522e310..c55f0cf84 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,7 @@ export type HostComponentNames = { text: string; textInput: string; switch: string; + scrollView: string; modal: string; }; diff --git a/src/helpers/host-component-names.tsx b/src/helpers/host-component-names.tsx index 02a984cbd..6fc606916 100644 --- a/src/helpers/host-component-names.tsx +++ b/src/helpers/host-component-names.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ReactTestInstance } from 'react-test-renderer'; -import { Modal, Switch, Text, TextInput, View } from 'react-native'; +import { Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native'; import { configureInternal, getConfig, HostComponentNames } from '../config'; import { renderWithAct } from '../render-act'; import { HostTestInstance } from './component-tree'; @@ -35,6 +35,7 @@ function detectHostComponentNames(): HostComponentNames { Hello + ); @@ -43,6 +44,7 @@ function detectHostComponentNames(): HostComponentNames { text: getByTestId(renderer.root, 'text').type as string, textInput: getByTestId(renderer.root, 'textInput').type as string, switch: getByTestId(renderer.root, 'switch').type as string, + scrollView: getByTestId(renderer.root, 'scrollView').type as string, modal: getByTestId(renderer.root, 'modal').type as string, }; } catch (error) { @@ -89,6 +91,16 @@ export function isHostTextInput( return element?.type === getHostComponentNames().textInput; } +/** + * Checks if the given element is a host ScrollView. + * @param element The element to check. + */ +export function isHostScrollView( + element?: ReactTestInstance | null +): element is HostTestInstance { + return element?.type === getHostComponentNames().scrollView; +} + /** * Checks if the given element is a host Modal. * @param element The element to check. diff --git a/src/helpers/object.ts b/src/helpers/object.ts new file mode 100644 index 000000000..afb17b01e --- /dev/null +++ b/src/helpers/object.ts @@ -0,0 +1,10 @@ +export function pick(object: T, keys: (keyof T)[]): Partial { + const result: Partial = {}; + keys.forEach((key) => { + if (object[key] !== undefined) { + result[key] = object[key]; + } + }); + + return result; +} diff --git a/src/test-utils/events.ts b/src/test-utils/events.ts index c34095346..914ccf2e7 100644 --- a/src/test-utils/events.ts +++ b/src/test-utils/events.ts @@ -1,4 +1,4 @@ -interface EventEntry { +export interface EventEntry { name: string; payload: any; } diff --git a/src/user-event/event-builder/index.ts b/src/user-event/event-builder/index.ts index 04d21d268..bee87cff4 100644 --- a/src/user-event/event-builder/index.ts +++ b/src/user-event/event-builder/index.ts @@ -1,7 +1,9 @@ import { CommonEventBuilder } from './common'; +import { ScrollViewEventBuilder } from './scroll-view'; import { TextInputEventBuilder } from './text-input'; export const EventBuilder = { Common: CommonEventBuilder, + ScrollView: ScrollViewEventBuilder, TextInput: TextInputEventBuilder, }; diff --git a/src/user-event/event-builder/scroll-view.ts b/src/user-event/event-builder/scroll-view.ts new file mode 100644 index 000000000..d4c2f62a1 --- /dev/null +++ b/src/user-event/event-builder/scroll-view.ts @@ -0,0 +1,32 @@ +/** + * Experimental values: + * - iOS: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 5.333333333333333}, "contentSize": {"height": 1676.6666259765625, "width": 390}, "layoutMeasurement": {"height": 753, "width": 390}, "zoomScale": 1}` + * - Android: `{"contentInset": {"bottom": 0, "left": 0, "right": 0, "top": 0}, "contentOffset": {"x": 0, "y": 31.619047164916992}, "contentSize": {"height": 1624.761962890625, "width": 411.4285583496094}, "layoutMeasurement": {"height": 785.5238037109375, "width": 411.4285583496094}, "responderIgnoreScroll": true, "target": 139, "velocity": {"x": -1.3633992671966553, "y": -1.3633992671966553}}` + */ + +/** + * Scroll position of a scrollable element. + */ +export interface ContentOffset { + y: number; + x: number; +} + +export const ScrollViewEventBuilder = { + scroll: (offset: ContentOffset = { y: 0, x: 0 }) => { + return { + nativeEvent: { + contentInset: { bottom: 0, left: 0, right: 0, top: 0 }, + contentOffset: { y: offset.y, x: offset.x }, + contentSize: { height: 0, width: 0 }, + layoutMeasurement: { + height: 0, + width: 0, + }, + responderIgnoreScroll: true, + target: 0, + velocity: { y: 0, x: 0 }, + }, + }; + }, +}; diff --git a/src/user-event/index.ts b/src/user-event/index.ts index ee4511ad3..6d8e50b63 100644 --- a/src/user-event/index.ts +++ b/src/user-event/index.ts @@ -2,6 +2,7 @@ import { ReactTestInstance } from 'react-test-renderer'; import { setup } from './setup'; import { PressOptions } from './press'; import { TypeOptions } from './type'; +import { ScrollToOptions } from './scroll'; export { UserEventConfig } from './setup'; @@ -15,4 +16,6 @@ export const userEvent = { type: (element: ReactTestInstance, text: string, options?: TypeOptions) => setup().type(element, text, options), clear: (element: ReactTestInstance) => setup().clear(element), + scrollTo: (element: ReactTestInstance, options: ScrollToOptions) => + setup().scrollTo(element, options), }; diff --git a/src/user-event/scroll/__tests__/__snapshots__/scrollTo-flatList.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scrollTo-flatList.tsx.snap new file mode 100644 index 000000000..fd58a7903 --- /dev/null +++ b/src/user-event/scroll/__tests__/__snapshots__/scrollTo-flatList.tsx.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` +[ + { + "name": "scrollBeginDrag", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 0, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scroll", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 25, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scroll", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 50, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scroll", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 75, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scrollEndDrag", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 100, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, +] +`; diff --git a/src/user-event/scroll/__tests__/__snapshots__/scrollTo.test.tsx.snap b/src/user-event/scroll/__tests__/__snapshots__/scrollTo.test.tsx.snap new file mode 100644 index 000000000..58307b017 --- /dev/null +++ b/src/user-event/scroll/__tests__/__snapshots__/scrollTo.test.tsx.snap @@ -0,0 +1,161 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`scrollTo() supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = ` +[ + { + "name": "scrollBeginDrag", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 0, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scroll", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 25, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scroll", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 50, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scroll", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 75, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, + { + "name": "scrollEndDrag", + "payload": { + "nativeEvent": { + "contentInset": { + "bottom": 0, + "left": 0, + "right": 0, + "top": 0, + }, + "contentOffset": { + "x": 0, + "y": 100, + }, + "contentSize": { + "height": 0, + "width": 0, + }, + "layoutMeasurement": { + "height": 0, + "width": 0, + }, + "responderIgnoreScroll": true, + "target": 0, + "velocity": { + "x": 0, + "y": 0, + }, + }, + }, + }, +] +`; diff --git a/src/user-event/scroll/__tests__/scrollTo-flatList.tsx b/src/user-event/scroll/__tests__/scrollTo-flatList.tsx new file mode 100644 index 000000000..67dcc1a28 --- /dev/null +++ b/src/user-event/scroll/__tests__/scrollTo-flatList.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; +import { FlatList, ScrollViewProps, Text } from 'react-native'; +import { EventEntry, createEventLogger } from '../../../test-utils'; +import { render, screen } from '../../..'; +import { userEvent } from '../..'; + +const data = ['A', 'B', 'C', 'D', 'E', 'F', 'G']; + +function mapEventsToShortForm(events: EventEntry[]) { + return events.map((e) => [ + e.name, + e.payload.nativeEvent.contentOffset.y, + e.payload.nativeEvent.contentOffset.x, + ]); +} + +function renderFlatListWithToolkit(props: ScrollViewProps = {}) { + const { events, logEvent } = createEventLogger(); + + const renderItem = (title: string) => {title}; + render( + renderItem(item)} + {...props} + /> + ); + + return { events }; +} + +describe('scrollTo() with FlatList', () => { + it('supports vertical drag scroll', async () => { + const { events } = renderFlatListWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('flatList'), { y: 100 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ]); + expect(events).toMatchSnapshot('scrollTo({ y: 100 })'); + }); + + it('supports horizontal drag scroll', async () => { + const { events } = renderFlatListWithToolkit({ horizontal: true }); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('flatList'), { x: 100 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 0, 25], + ['scroll', 0, 50], + ['scroll', 0, 75], + ['scrollEndDrag', 0, 100], + ]); + }); +}); diff --git a/src/user-event/scroll/__tests__/scrollTo.test.tsx b/src/user-event/scroll/__tests__/scrollTo.test.tsx new file mode 100644 index 000000000..9a0ff2464 --- /dev/null +++ b/src/user-event/scroll/__tests__/scrollTo.test.tsx @@ -0,0 +1,211 @@ +import * as React from 'react'; +import { ScrollView, ScrollViewProps, View } from 'react-native'; +import { EventEntry, createEventLogger } from '../../../test-utils'; +import { render, screen } from '../../..'; +import { userEvent } from '../..'; + +function mapEventsToShortForm(events: EventEntry[]) { + return events.map((e) => [ + e.name, + e.payload.nativeEvent.contentOffset.y, + e.payload.nativeEvent.contentOffset.x, + ]); +} + +function renderScrollViewWithToolkit(props: ScrollViewProps = {}) { + const { events, logEvent } = createEventLogger(); + + render( + + ); + + return { events }; +} + +beforeEach(() => { + jest.useRealTimers(); +}); + +describe('scrollTo()', () => { + it('supports vertical drag scroll', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { y: 100 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ]); + expect(events).toMatchSnapshot('scrollTo({ y: 100 })'); + }); + + it('supports horizontal drag scroll', async () => { + const { events } = renderScrollViewWithToolkit({ horizontal: true }); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { x: 100 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 0, 25], + ['scroll', 0, 50], + ['scroll', 0, 75], + ['scrollEndDrag', 0, 100], + ]); + }); + + it('supports vertical momentum scroll', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { + y: 100, + momentumY: 120, + }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ['momentumScrollBegin', 100, 0], + ['scroll', 110, 0], + ['scroll', 115, 0], + ['scroll', 117.5, 0], + ['scroll', 120, 0], + ['momentumScrollEnd', 120, 0], + ]); + }); + + test('works with fake timers', async () => { + jest.useFakeTimers(); + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { y: 100 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ]); + }); + + test('remembers previous scroll position', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { y: 100 }); + await user.scrollTo(screen.getByTestId('scrollView'), { y: 200 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ['scrollBeginDrag', 100, 0], + ['scroll', 125, 0], + ['scroll', 150, 0], + ['scroll', 175, 0], + ['scrollEndDrag', 200, 0], + ]); + }); + + it('validates vertical scroll direction', async () => { + renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await expect(() => + user.scrollTo(screen.getByTestId('scrollView'), { x: 100 }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"scrollTo() expected only vertical scroll options: "y" and "momentumY" for vertical "ScrollView" element but received {"x": 100}"` + ); + }); + + it('validates horizontal scroll direction', async () => { + renderScrollViewWithToolkit({ horizontal: true }); + const user = userEvent.setup(); + + await expect(() => + user.scrollTo(screen.getByTestId('scrollView'), { y: 100 }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"scrollTo() expected only horizontal scroll options: "x" and "momentumX" for horizontal "ScrollView" element but received {"y": 100}"` + ); + }); + + it('generates single drag step if already at target offset', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { y: 0 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scrollEndDrag', 0, 0], + ]); + }); + + it('generates single momentum step if already at target offset', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + await user.scrollTo(screen.getByTestId('scrollView'), { + y: 100, + momentumY: 100, + }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ['momentumScrollBegin', 100, 0], + ['scroll', 100, 0], + ['momentumScrollEnd', 100, 0], + ]); + }); + + it('generates no steps for scroll without offset', async () => { + const { events } = renderScrollViewWithToolkit(); + const user = userEvent.setup(); + + // @ts-expect-error intentionally omitting required options + await user.scrollTo(screen.getByTestId('scrollView'), {}); + expect(mapEventsToShortForm(events)).toEqual([]); + }); + + it('does NOT work on View', async () => { + const screen = render(); + const user = userEvent.setup(); + + await expect( + user.scrollTo(screen.getByTestId('view'), { y: 20 }) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"scrollTo() works only with host "ScrollView" elements. Passed element has type "View"."` + ); + }); + + test('is accessible directly in userEvent', async () => { + const { events } = renderScrollViewWithToolkit(); + + await userEvent.scrollTo(screen.getByTestId('scrollView'), { y: 100 }); + expect(mapEventsToShortForm(events)).toEqual([ + ['scrollBeginDrag', 0, 0], + ['scroll', 25, 0], + ['scroll', 50, 0], + ['scroll', 75, 0], + ['scrollEndDrag', 100, 0], + ]); + }); +}); diff --git a/src/user-event/scroll/index.ts b/src/user-event/scroll/index.ts new file mode 100644 index 000000000..5f1ac780f --- /dev/null +++ b/src/user-event/scroll/index.ts @@ -0,0 +1 @@ +export { ScrollToOptions, scrollTo } from './scrollTo'; diff --git a/src/user-event/scroll/scrollTo.ts b/src/user-event/scroll/scrollTo.ts new file mode 100644 index 000000000..6ddd066be --- /dev/null +++ b/src/user-event/scroll/scrollTo.ts @@ -0,0 +1,175 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { stringify } from 'jest-matcher-utils'; +import { UserEventConfig, UserEventInstance } from '../setup'; +import { EventBuilder } from '../event-builder'; +import { ErrorWithStack } from '../../helpers/errors'; +import { isHostScrollView } from '../../helpers/host-component-names'; +import { pick } from '../../helpers/object'; +import { dispatchEvent, wait } from '../utils'; +import { ContentOffset } from '../event-builder/scroll-view'; +import { + createScrollSteps, + inertialInterpolator, + linearInterpolator, +} from './utils'; +import { getElementScrollOffset, setElementScrollOffset } from './state'; + +export interface VerticalScrollToOptions { + y: number; + momentumY?: number; + + // Vertical scroll should not contain horizontal scroll part. + x?: never; + momentumX?: never; +} + +export interface HorizontalScrollToOptions { + x: number; + momentumX?: number; + + // Horizontal scroll should not contain vertical scroll part. + y?: never; + momentumY?: never; +} + +export type ScrollToOptions = + | VerticalScrollToOptions + | HorizontalScrollToOptions; + +export async function scrollTo( + this: UserEventInstance, + element: ReactTestInstance, + options: ScrollToOptions +): Promise { + if (!isHostScrollView(element)) { + throw new ErrorWithStack( + `scrollTo() works only with host "ScrollView" elements. Passed element has type "${element.type}".`, + scrollTo + ); + } + + ensureScrollViewDirection(element, options); + + const initialPosition = getElementScrollOffset(element); + const dragSteps = createScrollSteps( + { y: options.y, x: options.x }, + initialPosition, + linearInterpolator + ); + await emitDragScrollEvents(this.config, element, dragSteps); + + const momentumStart = dragSteps.at(-1) ?? initialPosition; + const momentumSteps = createScrollSteps( + { y: options.momentumY, x: options.momentumX }, + momentumStart, + inertialInterpolator + ); + await emitMomentumScrollEvents(this.config, element, momentumSteps); + + const finalPosition = + momentumSteps.at(-1) ?? dragSteps.at(-1) ?? initialPosition; + setElementScrollOffset(element, finalPosition); +} + +async function emitDragScrollEvents( + config: UserEventConfig, + element: ReactTestInstance, + scrollSteps: ContentOffset[] +) { + if (scrollSteps.length === 0) { + return; + } + + await wait(config); + dispatchEvent( + element, + 'scrollBeginDrag', + EventBuilder.ScrollView.scroll(scrollSteps[0]) + ); + + // Note: experimentally, in case of drag scroll the last scroll step + // will not trigger `scroll` event. + // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events + for (let i = 1; i < scrollSteps.length - 1; i += 1) { + await wait(config); + dispatchEvent( + element, + 'scroll', + EventBuilder.ScrollView.scroll(scrollSteps[i]) + ); + } + + await wait(config); + const lastStep = scrollSteps.at(-1); + dispatchEvent( + element, + 'scrollEndDrag', + EventBuilder.ScrollView.scroll(lastStep) + ); +} + +async function emitMomentumScrollEvents( + config: UserEventConfig, + element: ReactTestInstance, + scrollSteps: ContentOffset[] +) { + if (scrollSteps.length === 0) { + return; + } + + await wait(config); + dispatchEvent( + element, + 'momentumScrollBegin', + EventBuilder.ScrollView.scroll(scrollSteps[0]) + ); + + // Note: experimentally, in case of momentum scroll the last scroll step + // will trigger `scroll` event. + // See: https://github.com/callstack/react-native-testing-library/wiki/ScrollView-Events + for (let i = 1; i < scrollSteps.length; i += 1) { + await wait(config); + dispatchEvent( + element, + 'scroll', + EventBuilder.ScrollView.scroll(scrollSteps[i]) + ); + } + + await wait(config); + const lastStep = scrollSteps.at(-1); + dispatchEvent( + element, + 'momentumScrollEnd', + EventBuilder.ScrollView.scroll(lastStep) + ); +} + +function ensureScrollViewDirection( + element: ReactTestInstance, + options: ScrollToOptions +) { + const isVerticalScrollView = element.props.horizontal !== true; + + const hasHorizontalScrollOptions = + options.x !== undefined || options.momentumX !== undefined; + if (isVerticalScrollView && hasHorizontalScrollOptions) { + throw new ErrorWithStack( + `scrollTo() expected only vertical scroll options: "y" and "momentumY" for vertical "ScrollView" element but received ${stringify( + pick(options, ['x', 'momentumX']) + )}`, + scrollTo + ); + } + + const hasVerticalScrollOptions = + options.y !== undefined || options.momentumY !== undefined; + if (!isVerticalScrollView && hasVerticalScrollOptions) { + throw new ErrorWithStack( + `scrollTo() expected only horizontal scroll options: "x" and "momentumX" for horizontal "ScrollView" element but received ${stringify( + pick(options, ['y', 'momentumY']) + )}`, + scrollTo + ); + } +} diff --git a/src/user-event/scroll/state.ts b/src/user-event/scroll/state.ts new file mode 100644 index 000000000..722209189 --- /dev/null +++ b/src/user-event/scroll/state.ts @@ -0,0 +1,17 @@ +import { ReactTestInstance } from 'react-test-renderer'; +import { ContentOffset } from '../event-builder/scroll-view'; + +const scrollOffsetForElement = new WeakMap(); + +export function getElementScrollOffset( + element: ReactTestInstance +): ContentOffset { + return scrollOffsetForElement.get(element) ?? { x: 0, y: 0 }; +} + +export function setElementScrollOffset( + element: ReactTestInstance, + scrollState: ContentOffset +) { + scrollOffsetForElement.set(element, scrollState); +} diff --git a/src/user-event/scroll/utils.ts b/src/user-event/scroll/utils.ts new file mode 100644 index 000000000..a70b670a7 --- /dev/null +++ b/src/user-event/scroll/utils.ts @@ -0,0 +1,79 @@ +import { ContentOffset } from '../event-builder/scroll-view'; + +const DEFAULT_STEPS_COUNT = 5; + +type InterpolatorFn = (end: number, start: number, steps: number) => number[]; + +export function createScrollSteps( + target: Partial, + initialOffset: ContentOffset, + interpolator: InterpolatorFn +): ContentOffset[] { + if (target.y != null) { + return interpolator(target.y, initialOffset.y, DEFAULT_STEPS_COUNT).map( + (y) => ({ y, x: initialOffset.x }) + ); + } + + if (target.x != null) { + return interpolator(target.x, initialOffset.x, DEFAULT_STEPS_COUNT).map( + (x) => ({ x, y: initialOffset.y }) + ); + } + + return []; +} + +/** + * Generate linear scroll values (with equal steps). + */ +export function linearInterpolator( + end: number, + start: number, + steps: number +): number[] { + if (end === start) { + return [end, start]; + } + + const result = []; + for (let i = 0; i < steps; i += 1) { + result.push(lerp(start, end, i / (steps - 1))); + } + + return result; +} + +/** + * Generate inertial scroll values (exponentially slowing down). + */ +export function inertialInterpolator( + end: number, + start: number, + steps: number +): number[] { + if (end === start) { + return [end, start]; + } + + const result = []; + let factor = 1; + for (let i = 0; i < steps - 1; i += 1) { + result.push(lerp(end, start, factor)); + factor /= 2; + } + + result.push(end); + return result; +} + +/** + * Linear interpolation function + * @param v0 initial value (when t = 0) + * @param v1 final value (when t = 1) + * @param t interpolation factor form 0 to 1 + * @returns interpolated value between v0 and v1 + */ +export function lerp(v0: number, v1: number, t: number) { + return v0 + t * (v1 - v0); +} diff --git a/src/user-event/setup/setup.ts b/src/user-event/setup/setup.ts index db7cbc2dc..6c790a002 100644 --- a/src/user-event/setup/setup.ts +++ b/src/user-event/setup/setup.ts @@ -3,6 +3,7 @@ import { jestFakeTimersAreEnabled } from '../../helpers/timers'; import { PressOptions, press, longPress } from '../press'; import { TypeOptions, type } from '../type'; import { clear } from '../clear'; +import { ScrollToOptions, scrollTo } from '../scroll'; export interface UserEventSetupOptions { /** @@ -122,6 +123,17 @@ export interface UserEventInstance { * @param element TextInput element to clear */ clear: (element: ReactTestInstance) => Promise; + + /** + * Simlate user scorlling a ScrollView element. + * + * @param element ScrollView element + * @returns + */ + scrollTo: ( + element: ReactTestInstance, + options: ScrollToOptions + ) => Promise; } function createInstance(config: UserEventConfig): UserEventInstance { @@ -135,6 +147,7 @@ function createInstance(config: UserEventConfig): UserEventInstance { longPress: longPress.bind(instance), type: type.bind(instance), clear: clear.bind(instance), + scrollTo: scrollTo.bind(instance), }; Object.assign(instance, api); diff --git a/website/docs/UserEvent.md b/website/docs/UserEvent.md index 81cee6758..26695123e 100644 --- a/website/docs/UserEvent.md +++ b/website/docs/UserEvent.md @@ -178,3 +178,56 @@ The `textInput` event is sent only for mutliline text inputs. **Leaving the element**: - `endEditing` - `blur` + +## `scroll()` + +```ts +type( + element: ReactTestInstance, + options: { + y: number, + momentumY?: number, + } | { + x: number, + momentumX?: number, + } +``` + +Example +```ts +const user = userEvent.setup(); +await user.scrollTo(scrollView, { y: 100, momentumY: 200 }); +``` + +This helper simulates user scrolling a host `ScrollView` element. + +This function supports only host `ScrollView` elements, passing other element types will result in error. Note that `FlatList` is accepted as it renders to a host `ScrolLView` element, however in the current iteration we focus only on base `ScrollView` only features. + +Scroll interaction should match `ScrollView` element direction. For vertical scroll view (default or explicit `horizontal={false}`) you should pass only `y` (and optionally also `momentumY`) option, for horizontal scroll view (`horizontal={true}`) you should pass only `x` (and optionally `momentumX`) option. + +Each scroll interaction consists of a mandatory drag scroll part which simulates user dragging the scroll view with his finger (`y` or `x` option). This may optionally be followed by a momentum scroll movement which simulates the inertial movement of scroll view content after the user lifts his finger up (`momentumY` or `momentumX` options). + +### Options {#type-options} + - `y` - target vertical drag scroll position + - `x` - target horizontal drag scroll position + - `momentumY` - target vertical momentum scroll position + - `momentumX` - target horizontal momentum scroll position + +User Event will generate a number of intermediate scroll steps to simulate user scroll interaction. You should not rely on exact number or values of these scrolls steps as they might be change in the future version. + +This function will remember where the last scroll ended, so subsequent scroll interaction will starts from that positition. The initial scroll position will be assumed to be `{ y: 0, x: 0 }`. + +### Sequence of events + +The sequence of events depends whether scroll includes optional momentum scroll component. + +**Drag scroll**: +- `scrollBeginDrag` +- `scroll` (multiple times) +- `scrollEndDrag` + +**Momentum scroll (optional)**: +- `momentumScrollBegin` +- `scroll` (multiple events) +- `momentumScrollEnd` +