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)