Skip to content

Commit a7ea95d

Browse files
feat: user event scroll (#1445)
* feat: setup dummy scroll endpoint * chore: use ScrollEventBuilder * chore: enhance API endpoint with parameters * chore: emit multiple scroll events * fix: intermediate steps offset values * feat: momentum scroll * chore: add scroll to userEvent * feat: scrollToTop * feat: remember scroll position for element * fix: setting and getting scroll state * chore: validate host component type * chore: validate and emit intermediate callbacks in scrollToTop * chore: fix ts * refactor: rename scroll to scrollTo * refactor: y & x scrollTo params * refactor: refactor y, x params to support explicit steps * chore: fix typecheck * refactor: improve typing * refactor: momentumY, momentumX options * docs: initial docs for `scrollTo` * refactor: descope `scrollToTop` variant * docs: tweaks * refactor: improve TS typing by separating vertical/horizontal scroll * refactor: wrap scroll event in nativeEvent wrapper * refactor: clean up implementation * chore: add FlatList tests * refactor: remove explicit steps * refactor: introduce inertial interpolator for momentum scroll * docs: update docs * chore: update snapshots * refactor: code review changes * feat: validate scroll direction * docs: tweaks * docs: tweak * refactor: add more tests to improve code cov * refactor: improve errors * refactor: code review tweaks * refactor: add wait calls * refactor: tweaks, tweaks, tweaks * refactor: final tweaks --------- Co-authored-by: Maciej Jastrzębski <[email protected]>
1 parent 3b9c4da commit a7ea95d

File tree

19 files changed

+1017
-3
lines changed

19 files changed

+1017
-3
lines changed

src/__tests__/config.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,19 @@ test('resetToDefaults() resets config to defaults', () => {
3535

3636
test('resetToDefaults() resets internal config to defaults', () => {
3737
configureInternal({
38-
hostComponentNames: { text: 'A', textInput: 'A', switch: 'A', modal: 'A' },
38+
hostComponentNames: {
39+
text: 'A',
40+
textInput: 'A',
41+
switch: 'A',
42+
scrollView: 'A',
43+
modal: 'A',
44+
},
3945
});
4046
expect(getConfig().hostComponentNames).toEqual({
4147
text: 'A',
4248
textInput: 'A',
4349
switch: 'A',
50+
scrollView: 'A',
4451
modal: 'A',
4552
});
4653

src/__tests__/host-component-names.test.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ describe('getHostComponentNames', () => {
2121
text: 'banana',
2222
textInput: 'banana',
2323
switch: 'banana',
24+
scrollView: 'banana',
2425
modal: 'banana',
2526
},
2627
});
@@ -29,6 +30,7 @@ describe('getHostComponentNames', () => {
2930
text: 'banana',
3031
textInput: 'banana',
3132
switch: 'banana',
33+
scrollView: 'banana',
3234
modal: 'banana',
3335
});
3436
});
@@ -42,6 +44,7 @@ describe('getHostComponentNames', () => {
4244
text: 'Text',
4345
textInput: 'TextInput',
4446
switch: 'RCTSwitch',
47+
scrollView: 'RCTScrollView',
4548
modal: 'Modal',
4649
});
4750
expect(getConfig().hostComponentNames).toBe(hostComponentNames);
@@ -71,6 +74,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
7174
text: 'Text',
7275
textInput: 'TextInput',
7376
switch: 'RCTSwitch',
77+
scrollView: 'RCTScrollView',
7478
modal: 'Modal',
7579
});
7680
});
@@ -81,6 +85,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
8185
text: 'banana',
8286
textInput: 'banana',
8387
switch: 'banana',
88+
scrollView: 'banana',
8489
modal: 'banana',
8590
},
8691
});
@@ -91,6 +96,7 @@ describe('configureHostComponentNamesIfNeeded', () => {
9196
text: 'banana',
9297
textInput: 'banana',
9398
switch: 'banana',
99+
scrollView: 'banana',
94100
modal: 'banana',
95101
});
96102
});

src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type HostComponentNames = {
2424
text: string;
2525
textInput: string;
2626
switch: string;
27+
scrollView: string;
2728
modal: string;
2829
};
2930

src/helpers/host-component-names.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as React from 'react';
22
import { ReactTestInstance } from 'react-test-renderer';
3-
import { Modal, Switch, Text, TextInput, View } from 'react-native';
3+
import { Modal, ScrollView, Switch, Text, TextInput, View } from 'react-native';
44
import { configureInternal, getConfig, HostComponentNames } from '../config';
55
import { renderWithAct } from '../render-act';
66
import { HostTestInstance } from './component-tree';
@@ -35,6 +35,7 @@ function detectHostComponentNames(): HostComponentNames {
3535
<Text testID="text">Hello</Text>
3636
<TextInput testID="textInput" />
3737
<Switch testID="switch" />
38+
<ScrollView testID="scrollView" />
3839
<Modal testID="modal" />
3940
</View>
4041
);
@@ -43,6 +44,7 @@ function detectHostComponentNames(): HostComponentNames {
4344
text: getByTestId(renderer.root, 'text').type as string,
4445
textInput: getByTestId(renderer.root, 'textInput').type as string,
4546
switch: getByTestId(renderer.root, 'switch').type as string,
47+
scrollView: getByTestId(renderer.root, 'scrollView').type as string,
4648
modal: getByTestId(renderer.root, 'modal').type as string,
4749
};
4850
} catch (error) {
@@ -89,6 +91,16 @@ export function isHostTextInput(
8991
return element?.type === getHostComponentNames().textInput;
9092
}
9193

94+
/**
95+
* Checks if the given element is a host ScrollView.
96+
* @param element The element to check.
97+
*/
98+
export function isHostScrollView(
99+
element?: ReactTestInstance | null
100+
): element is HostTestInstance {
101+
return element?.type === getHostComponentNames().scrollView;
102+
}
103+
92104
/**
93105
* Checks if the given element is a host Modal.
94106
* @param element The element to check.

src/helpers/object.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function pick<T extends {}>(object: T, keys: (keyof T)[]): Partial<T> {
2+
const result: Partial<T> = {};
3+
keys.forEach((key) => {
4+
if (object[key] !== undefined) {
5+
result[key] = object[key];
6+
}
7+
});
8+
9+
return result;
10+
}

src/test-utils/events.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
interface EventEntry {
1+
export interface EventEntry {
22
name: string;
33
payload: any;
44
}

src/user-event/event-builder/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { CommonEventBuilder } from './common';
2+
import { ScrollViewEventBuilder } from './scroll-view';
23
import { TextInputEventBuilder } from './text-input';
34

45
export const EventBuilder = {
56
Common: CommonEventBuilder,
7+
ScrollView: ScrollViewEventBuilder,
68
TextInput: TextInputEventBuilder,
79
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Experimental values:
3+
* - 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}`
4+
* - 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}}`
5+
*/
6+
7+
/**
8+
* Scroll position of a scrollable element.
9+
*/
10+
export interface ContentOffset {
11+
y: number;
12+
x: number;
13+
}
14+
15+
export const ScrollViewEventBuilder = {
16+
scroll: (offset: ContentOffset = { y: 0, x: 0 }) => {
17+
return {
18+
nativeEvent: {
19+
contentInset: { bottom: 0, left: 0, right: 0, top: 0 },
20+
contentOffset: { y: offset.y, x: offset.x },
21+
contentSize: { height: 0, width: 0 },
22+
layoutMeasurement: {
23+
height: 0,
24+
width: 0,
25+
},
26+
responderIgnoreScroll: true,
27+
target: 0,
28+
velocity: { y: 0, x: 0 },
29+
},
30+
};
31+
},
32+
};

src/user-event/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { ReactTestInstance } from 'react-test-renderer';
22
import { setup } from './setup';
33
import { PressOptions } from './press';
44
import { TypeOptions } from './type';
5+
import { ScrollToOptions } from './scroll';
56

67
export { UserEventConfig } from './setup';
78

@@ -15,4 +16,6 @@ export const userEvent = {
1516
type: (element: ReactTestInstance, text: string, options?: TypeOptions) =>
1617
setup().type(element, text, options),
1718
clear: (element: ReactTestInstance) => setup().clear(element),
19+
scrollTo: (element: ReactTestInstance, options: ScrollToOptions) =>
20+
setup().scrollTo(element, options),
1821
};
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`scrollTo() with FlatList supports vertical drag scroll: scrollTo({ y: 100 }) 1`] = `
4+
[
5+
{
6+
"name": "scrollBeginDrag",
7+
"payload": {
8+
"nativeEvent": {
9+
"contentInset": {
10+
"bottom": 0,
11+
"left": 0,
12+
"right": 0,
13+
"top": 0,
14+
},
15+
"contentOffset": {
16+
"x": 0,
17+
"y": 0,
18+
},
19+
"contentSize": {
20+
"height": 0,
21+
"width": 0,
22+
},
23+
"layoutMeasurement": {
24+
"height": 0,
25+
"width": 0,
26+
},
27+
"responderIgnoreScroll": true,
28+
"target": 0,
29+
"velocity": {
30+
"x": 0,
31+
"y": 0,
32+
},
33+
},
34+
},
35+
},
36+
{
37+
"name": "scroll",
38+
"payload": {
39+
"nativeEvent": {
40+
"contentInset": {
41+
"bottom": 0,
42+
"left": 0,
43+
"right": 0,
44+
"top": 0,
45+
},
46+
"contentOffset": {
47+
"x": 0,
48+
"y": 25,
49+
},
50+
"contentSize": {
51+
"height": 0,
52+
"width": 0,
53+
},
54+
"layoutMeasurement": {
55+
"height": 0,
56+
"width": 0,
57+
},
58+
"responderIgnoreScroll": true,
59+
"target": 0,
60+
"velocity": {
61+
"x": 0,
62+
"y": 0,
63+
},
64+
},
65+
},
66+
},
67+
{
68+
"name": "scroll",
69+
"payload": {
70+
"nativeEvent": {
71+
"contentInset": {
72+
"bottom": 0,
73+
"left": 0,
74+
"right": 0,
75+
"top": 0,
76+
},
77+
"contentOffset": {
78+
"x": 0,
79+
"y": 50,
80+
},
81+
"contentSize": {
82+
"height": 0,
83+
"width": 0,
84+
},
85+
"layoutMeasurement": {
86+
"height": 0,
87+
"width": 0,
88+
},
89+
"responderIgnoreScroll": true,
90+
"target": 0,
91+
"velocity": {
92+
"x": 0,
93+
"y": 0,
94+
},
95+
},
96+
},
97+
},
98+
{
99+
"name": "scroll",
100+
"payload": {
101+
"nativeEvent": {
102+
"contentInset": {
103+
"bottom": 0,
104+
"left": 0,
105+
"right": 0,
106+
"top": 0,
107+
},
108+
"contentOffset": {
109+
"x": 0,
110+
"y": 75,
111+
},
112+
"contentSize": {
113+
"height": 0,
114+
"width": 0,
115+
},
116+
"layoutMeasurement": {
117+
"height": 0,
118+
"width": 0,
119+
},
120+
"responderIgnoreScroll": true,
121+
"target": 0,
122+
"velocity": {
123+
"x": 0,
124+
"y": 0,
125+
},
126+
},
127+
},
128+
},
129+
{
130+
"name": "scrollEndDrag",
131+
"payload": {
132+
"nativeEvent": {
133+
"contentInset": {
134+
"bottom": 0,
135+
"left": 0,
136+
"right": 0,
137+
"top": 0,
138+
},
139+
"contentOffset": {
140+
"x": 0,
141+
"y": 100,
142+
},
143+
"contentSize": {
144+
"height": 0,
145+
"width": 0,
146+
},
147+
"layoutMeasurement": {
148+
"height": 0,
149+
"width": 0,
150+
},
151+
"responderIgnoreScroll": true,
152+
"target": 0,
153+
"velocity": {
154+
"x": 0,
155+
"y": 0,
156+
},
157+
},
158+
},
159+
},
160+
]
161+
`;

0 commit comments

Comments
 (0)