diff --git a/packages/react-events/npm/scroll.js b/packages/react-events/npm/scroll.js
new file mode 100644
index 0000000000000..03efb629c2688
--- /dev/null
+++ b/packages/react-events/npm/scroll.js
@@ -0,0 +1,7 @@
+'use strict';
+
+if (process.env.NODE_ENV === 'production') {
+ module.exports = require('./cjs/react-events-scroll.production.min.js');
+} else {
+ module.exports = require('./cjs/react-events-scroll.development.js');
+}
diff --git a/packages/react-events/package.json b/packages/react-events/package.json
index 083eef77ffbb8..8532fb25ff1ec 100644
--- a/packages/react-events/package.json
+++ b/packages/react-events/package.json
@@ -17,6 +17,7 @@
"focus.js",
"swipe.js",
"drag.js",
+ "scroll.js",
"focus-scope.js",
"index.js",
"build-info.json",
diff --git a/packages/react-events/scroll.js b/packages/react-events/scroll.js
new file mode 100644
index 0000000000000..93b6a35900635
--- /dev/null
+++ b/packages/react-events/scroll.js
@@ -0,0 +1,14 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+'use strict';
+
+const Scroll = require('./src/Scroll');
+
+module.exports = Scroll.default || Scroll;
diff --git a/packages/react-events/src/Scroll.js b/packages/react-events/src/Scroll.js
new file mode 100644
index 0000000000000..350f7ad5a6e27
--- /dev/null
+++ b/packages/react-events/src/Scroll.js
@@ -0,0 +1,218 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import type {
+ ReactResponderEvent,
+ ReactResponderContext,
+} from 'shared/ReactTypes';
+import {UserBlockingEvent} from 'shared/ReactTypes';
+import type {EventPriority} from 'shared/ReactTypes';
+
+import React from 'react';
+
+type ScrollProps = {
+ disabled: boolean,
+ onScroll: ScrollEvent => void,
+ onScrollDragStart: ScrollEvent => void,
+ onScrollDragEnd: ScrollEvent => void,
+ onScrollMomentumStart: ScrollEvent => void,
+ onScrollMomentumEnd: ScrollEvent => void,
+};
+
+type ScrollState = {
+ pointerType: PointerType,
+ scrollTarget: null | Element | Document,
+ isPointerDown: boolean,
+};
+
+type ScrollEventType =
+ | 'scroll'
+ | 'scrolldragstart'
+ | 'scrolldragend'
+ | 'scrollmomentumstart'
+ | 'scrollmomentumend';
+
+type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch';
+
+type ScrollDirection = '' | 'up' | 'down' | 'left' | 'right';
+
+type ScrollEvent = {|
+ direction: ScrollDirection,
+ target: Element | Document,
+ type: ScrollEventType,
+ pointerType: PointerType,
+ timeStamp: number,
+ clientX: null | number,
+ clientY: null | number,
+ pageX: null | number,
+ pageY: null | number,
+ screenX: null | number,
+ screenY: null | number,
+ x: null | number,
+ y: null | number,
+|};
+
+const targetEventTypes = ['scroll', 'pointerdown', 'keyup'];
+const rootEventTypes = ['pointermove', 'pointerup', 'pointercancel'];
+
+function createScrollEvent(
+ event: ?ReactResponderEvent,
+ context: ReactResponderContext,
+ type: ScrollEventType,
+ target: Element | Document,
+ pointerType: PointerType,
+): ScrollEvent {
+ let clientX = null;
+ let clientY = null;
+ let pageX = null;
+ let pageY = null;
+ let screenX = null;
+ let screenY = null;
+
+ if (event) {
+ const nativeEvent = (event.nativeEvent: any);
+ ({clientX, clientY, pageX, pageY, screenX, screenY} = nativeEvent);
+ }
+
+ return {
+ target,
+ type,
+ pointerType,
+ direction: '', // TODO
+ timeStamp: context.getTimeStamp(),
+ clientX,
+ clientY,
+ pageX,
+ pageY,
+ screenX,
+ screenY,
+ x: clientX,
+ y: clientY,
+ };
+}
+
+function dispatchEvent(
+ event: ?ReactResponderEvent,
+ context: ReactResponderContext,
+ state: ScrollState,
+ name: ScrollEventType,
+ listener: (e: Object) => void,
+ eventPriority: EventPriority,
+): void {
+ const target = ((state.scrollTarget: any): Element | Document);
+ const pointerType = state.pointerType;
+ const syntheticEvent = createScrollEvent(
+ event,
+ context,
+ name,
+ target,
+ pointerType,
+ );
+ context.dispatchEvent(syntheticEvent, listener, eventPriority);
+}
+
+const ScrollResponder = {
+ targetEventTypes,
+ createInitialState() {
+ return {
+ pointerType: '',
+ scrollTarget: null,
+ isPointerDown: false,
+ };
+ },
+ allowMultipleHostChildren: true,
+ stopLocalPropagation: true,
+ onEvent(
+ event: ReactResponderEvent,
+ context: ReactResponderContext,
+ props: ScrollProps,
+ state: ScrollState,
+ ): void {
+ const {target, type} = event;
+
+ if (props.disabled) {
+ if (state.isPointerDown) {
+ state.isPointerDown = false;
+ state.scrollTarget = null;
+ context.addRootEventTypes(rootEventTypes);
+ }
+ return;
+ }
+ const pointerType = context.getEventPointerType(event);
+
+ switch (type) {
+ case 'scroll': {
+ state.scrollTarget = ((target: any): Element | Document);
+ if (props.onScroll) {
+ dispatchEvent(
+ event,
+ context,
+ state,
+ 'scroll',
+ props.onScroll,
+ UserBlockingEvent,
+ );
+ }
+ break;
+ }
+ case 'keyup': {
+ state.pointerType = pointerType;
+ break;
+ }
+ case 'pointerdown': {
+ state.pointerType = pointerType;
+ if (!state.isPointerDown) {
+ state.isPointerDown = true;
+ context.addRootEventTypes(rootEventTypes);
+ }
+ break;
+ }
+ }
+ },
+ onRootEvent(
+ event: ReactResponderEvent,
+ context: ReactResponderContext,
+ props: ScrollProps,
+ state: ScrollState,
+ ) {
+ const {type} = event;
+ const pointerType = context.getEventPointerType(event);
+
+ switch (type) {
+ case 'pointercancel':
+ case 'pointerup': {
+ state.pointerType = pointerType;
+ if (state.isPointerDown) {
+ state.isPointerDown = false;
+ context.removeRootEventTypes(rootEventTypes);
+ }
+ break;
+ }
+ case 'pointermove': {
+ state.pointerType = pointerType;
+ }
+ }
+ },
+ onUnmount(
+ context: ReactResponderContext,
+ props: ScrollProps,
+ state: ScrollState,
+ ) {
+ // TODO
+ },
+ onOwnershipChange(
+ context: ReactResponderContext,
+ props: ScrollProps,
+ state: ScrollState,
+ ) {
+ // TODO
+ },
+};
+
+export default React.unstable_createEventComponent(ScrollResponder, 'Scroll');
diff --git a/packages/react-events/src/__tests__/Scroll-test.internal.js b/packages/react-events/src/__tests__/Scroll-test.internal.js
new file mode 100644
index 0000000000000..01c627df9978b
--- /dev/null
+++ b/packages/react-events/src/__tests__/Scroll-test.internal.js
@@ -0,0 +1,134 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+let React;
+let ReactFeatureFlags;
+let ReactDOM;
+let Scroll;
+
+const createEvent = (type, data) => {
+ const event = document.createEvent('CustomEvent');
+ event.initCustomEvent(type, true, true);
+ if (data != null) {
+ Object.entries(data).forEach(([key, value]) => {
+ event[key] = value;
+ });
+ }
+ return event;
+};
+
+describe('Scroll event responder', () => {
+ let container;
+
+ beforeEach(() => {
+ jest.resetModules();
+ ReactFeatureFlags = require('shared/ReactFeatureFlags');
+ ReactFeatureFlags.enableEventAPI = true;
+ React = require('react');
+ ReactDOM = require('react-dom');
+ Scroll = require('react-events/scroll');
+
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ });
+
+ afterEach(() => {
+ ReactDOM.render(null, container);
+ document.body.removeChild(container);
+ container = null;
+ });
+
+ describe('disabled', () => {
+ let onScroll, ref;
+
+ beforeEach(() => {
+ onScroll = jest.fn();
+ ref = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ it('prevents custom events being dispatched', () => {
+ ref.current.dispatchEvent(createEvent('scroll'));
+ expect(onScroll).not.toBeCalled();
+ });
+ });
+
+ describe('onScroll', () => {
+ let onScroll, ref;
+
+ beforeEach(() => {
+ onScroll = jest.fn();
+ ref = React.createRef();
+ const element = (
+
+
+
+ );
+ ReactDOM.render(element, container);
+ });
+
+ describe('is called after "scroll" event', () => {
+ it('with a mouse pointerType', () => {
+ ref.current.dispatchEvent(
+ createEvent('pointerdown', {
+ pointerType: 'mouse',
+ }),
+ );
+ ref.current.dispatchEvent(createEvent('scroll'));
+ expect(onScroll).toHaveBeenCalledTimes(1);
+ expect(onScroll).toHaveBeenCalledWith(
+ expect.objectContaining({pointerType: 'mouse', type: 'scroll'}),
+ );
+ });
+
+ it('with a touch pointerType', () => {
+ ref.current.dispatchEvent(
+ createEvent('pointerdown', {
+ pointerType: 'touch',
+ }),
+ );
+ ref.current.dispatchEvent(createEvent('scroll'));
+ expect(onScroll).toHaveBeenCalledTimes(1);
+ expect(onScroll).toHaveBeenCalledWith(
+ expect.objectContaining({pointerType: 'touch', type: 'scroll'}),
+ );
+ });
+
+ it('with a pen pointerType', () => {
+ ref.current.dispatchEvent(
+ createEvent('pointerdown', {
+ pointerType: 'pen',
+ }),
+ );
+ ref.current.dispatchEvent(createEvent('scroll'));
+ expect(onScroll).toHaveBeenCalledTimes(1);
+ expect(onScroll).toHaveBeenCalledWith(
+ expect.objectContaining({pointerType: 'pen', type: 'scroll'}),
+ );
+ });
+
+ it('with a keyboard pointerType', () => {
+ ref.current.dispatchEvent(createEvent('keydown'));
+ ref.current.dispatchEvent(createEvent('keyup'));
+ ref.current.dispatchEvent(createEvent('scroll'));
+ expect(onScroll).toHaveBeenCalledTimes(1);
+ expect(onScroll).toHaveBeenCalledWith(
+ expect.objectContaining({pointerType: 'keyboard', type: 'scroll'}),
+ );
+ });
+ });
+ });
+});
diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js
index 90dc854186978..79df34ffdf159 100644
--- a/scripts/rollup/bundles.js
+++ b/scripts/rollup/bundles.js
@@ -542,6 +542,21 @@ const bundles = [
global: 'ReactEventsDrag',
externals: ['react'],
},
+
+ {
+ bundleTypes: [
+ UMD_DEV,
+ UMD_PROD,
+ NODE_DEV,
+ NODE_PROD,
+ FB_WWW_DEV,
+ FB_WWW_PROD,
+ ],
+ moduleType: NON_FIBER_RENDERER,
+ entry: 'react-events/scroll',
+ global: 'ReactEventsScroll',
+ externals: ['react'],
+ },
];
// Based on deep-freeze by substack (public domain)