diff --git a/.eslintrc b/.eslintrc index 0d6250be3..919c9ea52 100644 --- a/.eslintrc +++ b/.eslintrc @@ -39,6 +39,7 @@ "$ReadOnly": false, "$ReadOnlyArray": false, "CSSStyleSheet": false, + "HTMLElement": false, "HTMLInputElement": false, "ReactClass": false, "ReactComponent": false, diff --git a/packages/react-native-web/src/exports/Text/index.js b/packages/react-native-web/src/exports/Text/index.js index 8cd77dded..9c68d6923 100644 --- a/packages/react-native-web/src/exports/Text/index.js +++ b/packages/react-native-web/src/exports/Text/index.js @@ -15,6 +15,7 @@ import css from '../StyleSheet/css'; import setAndForwardRef from '../../modules/setAndForwardRef'; import useElementLayout from '../../hooks/useElementLayout'; import usePlatformMethods from '../../hooks/usePlatformMethods'; +import useResponderEvents from '../../hooks/useResponderEvents'; import React, { forwardRef, useContext, useRef } from 'react'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from './TextAncestorContext'; @@ -107,6 +108,24 @@ const Text = forwardRef((props, ref) => { useElementLayout(hostRef, onLayout); usePlatformMethods(hostRef, ref, classList, style); + useResponderEvents(hostRef, { + onMoveShouldSetResponder, + onMoveShouldSetResponderCapture, + onResponderEnd, + onResponderGrant, + onResponderMove, + onResponderReject, + onResponderRelease, + onResponderStart, + onResponderTerminate, + onResponderTerminationRequest, + onScrollShouldSetResponder, + onScrollShouldSetResponderCapture, + onSelectionChangeShouldSetResponder, + onSelectionChangeShouldSetResponderCapture, + onStartShouldSetResponder, + onStartShouldSetResponderCapture + }); function createEnterHandler(fn) { return e => { @@ -138,29 +157,13 @@ const Text = forwardRef((props, ref) => { importantForAccessibility, nativeID, onBlur, + onContextMenu, onFocus, - onMoveShouldSetResponder, - onMoveShouldSetResponderCapture, - onResponderEnd, - onResponderGrant, - onResponderMove, - onResponderReject, - onResponderRelease, - onResponderStart, - onResponderTerminate, - onResponderTerminationRequest, - onScrollShouldSetResponder, - onScrollShouldSetResponderCapture, - onSelectionChangeShouldSetResponder, - onSelectionChangeShouldSetResponderCapture, - onStartShouldSetResponder, - onStartShouldSetResponderCapture, ref: setRef, style, testID, // unstable onClick: onPress != null ? createPressHandler(onPress) : null, - onContextMenu, onKeyDown: onPress != null ? createEnterHandler(onPress) : null, onMouseDown, onMouseEnter, diff --git a/packages/react-native-web/src/exports/Text/types.js b/packages/react-native-web/src/exports/Text/types.js index 81e870b78..c7d87ab0a 100644 --- a/packages/react-native-web/src/exports/Text/types.js +++ b/packages/react-native-web/src/exports/Text/types.js @@ -115,7 +115,7 @@ export type TextProps = { onResponderRelease?: (e: any) => void, onResponderStart?: (e: any) => void, onResponderTerminate?: (e: any) => void, - onResponderTerminationRequest?: (e: any) => void, + onResponderTerminationRequest?: (e: any) => boolean, onScrollShouldSetResponder?: (e: any) => boolean, onScrollShouldSetResponderCapture?: (e: any) => boolean, onSelectionChangeShouldSetResponder?: (e: any) => boolean, diff --git a/packages/react-native-web/src/exports/TextInput/index.js b/packages/react-native-web/src/exports/TextInput/index.js index 5456301f1..ca8c59d51 100644 --- a/packages/react-native-web/src/exports/TextInput/index.js +++ b/packages/react-native-web/src/exports/TextInput/index.js @@ -16,6 +16,7 @@ import setAndForwardRef from '../../modules/setAndForwardRef'; import useElementLayout from '../../hooks/useElementLayout'; import useLayoutEffect from '../../hooks/useLayoutEffect'; import { usePlatformInputMethods } from '../../hooks/usePlatformMethods'; +import useResponderEvents from '../../hooks/useResponderEvents'; import { forwardRef, useRef } from 'react'; import StyleSheet from '../StyleSheet'; import TextInputState from '../../modules/TextInputState'; @@ -286,6 +287,24 @@ const TextInput = forwardRef((props, ref) => { useElementLayout(hostRef, onLayout); usePlatformInputMethods(hostRef, ref, classList, style); + useResponderEvents(hostRef, { + onMoveShouldSetResponder, + onMoveShouldSetResponderCapture, + onResponderEnd, + onResponderGrant, + onResponderMove, + onResponderReject, + onResponderRelease, + onResponderStart, + onResponderTerminate, + onResponderTerminationRequest, + onScrollShouldSetResponder, + onScrollShouldSetResponderCapture, + onSelectionChangeShouldSetResponder, + onSelectionChangeShouldSetResponderCapture, + onStartShouldSetResponder, + onStartShouldSetResponderCapture + }); return createElement(component, { accessibilityLabel, @@ -310,22 +329,6 @@ const TextInput = forwardRef((props, ref) => { onKeyDown: handleKeyDown, onScroll, onSelect: handleSelectionChange, - onMoveShouldSetResponder, - onMoveShouldSetResponderCapture, - onResponderEnd, - onResponderGrant, - onResponderMove, - onResponderReject, - onResponderRelease, - onResponderStart, - onResponderTerminate, - onResponderTerminationRequest, - onScrollShouldSetResponder, - onScrollShouldSetResponderCapture, - onSelectionChangeShouldSetResponder, - onSelectionChangeShouldSetResponderCapture, - onStartShouldSetResponder, - onStartShouldSetResponderCapture, placeholder, pointerEvents, testID, diff --git a/packages/react-native-web/src/exports/View/index.js b/packages/react-native-web/src/exports/View/index.js index 7abad2aa0..433722047 100644 --- a/packages/react-native-web/src/exports/View/index.js +++ b/packages/react-native-web/src/exports/View/index.js @@ -15,6 +15,7 @@ import css from '../StyleSheet/css'; import setAndForwardRef from '../../modules/setAndForwardRef'; import useElementLayout from '../../hooks/useElementLayout'; import usePlatformMethods from '../../hooks/usePlatformMethods'; +import useResponderEvents from '../../hooks/useResponderEvents'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from '../Text/TextAncestorContext'; import React, { forwardRef, useContext, useRef } from 'react'; @@ -132,21 +133,7 @@ const View = forwardRef((props, ref) => { useElementLayout(hostRef, onLayout); usePlatformMethods(hostRef, ref, classList, style); - - return createElement('div', { - accessibilityLabel, - accessibilityLiveRegion, - accessibilityRelationship, - accessibilityRole, - accessibilityState, - accessibilityValue, - children, - classList, - importantForAccessibility, - nativeID, - onBlur, - onContextMenu, - onFocus, + useResponderEvents(hostRef, { onMoveShouldSetResponder, onMoveShouldSetResponderCapture, onResponderEnd, @@ -162,7 +149,23 @@ const View = forwardRef((props, ref) => { onSelectionChangeShouldSetResponder, onSelectionChangeShouldSetResponderCapture, onStartShouldSetResponder, - onStartShouldSetResponderCapture, + onStartShouldSetResponderCapture + }); + + return createElement('div', { + accessibilityLabel, + accessibilityLiveRegion, + accessibilityRelationship, + accessibilityRole, + accessibilityState, + accessibilityValue, + children, + classList, + importantForAccessibility, + nativeID, + onBlur, + onContextMenu, + onFocus, pointerEvents, ref: setRef, style, diff --git a/packages/react-native-web/src/exports/View/types.js b/packages/react-native-web/src/exports/View/types.js index a5e8126ba..51eed99fd 100644 --- a/packages/react-native-web/src/exports/View/types.js +++ b/packages/react-native-web/src/exports/View/types.js @@ -109,7 +109,7 @@ export type ViewProps = { onResponderRelease?: (e: any) => void, onResponderStart?: (e: any) => void, onResponderTerminate?: (e: any) => void, - onResponderTerminationRequest?: (e: any) => void, + onResponderTerminationRequest?: (e: any) => boolean, onScrollShouldSetResponder?: (e: any) => boolean, onScrollShouldSetResponderCapture?: (e: any) => boolean, onSelectionChangeShouldSetResponder?: (e: any) => boolean, diff --git a/packages/react-native-web/src/exports/createElement/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/createElement/__tests__/__snapshots__/index-test.js.snap index 4b97c76dc..e3e1c604d 100644 --- a/packages/react-native-web/src/exports/createElement/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/createElement/__tests__/__snapshots__/index-test.js.snap @@ -1,28 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`modules/createElement normalizes event.nativeEvent 1`] = ` -Object { - "_normalized": true, - "bubbles": undefined, - "cancelable": undefined, - "changedTouches": Array [], - "defaultPrevented": undefined, - "identifier": undefined, - "locationX": undefined, - "locationY": undefined, - "pageX": undefined, - "pageY": undefined, - "preventDefault": [Function], - "stopImmediatePropagation": [Function], - "stopPropagation": [Function], - "target": undefined, - "timestamp": 1496876171255, - "touches": Array [], - "type": undefined, - "which": undefined, -} -`; - exports[`modules/createElement renders different DOM elements 1`] = ``; exports[`modules/createElement renders different DOM elements 2`] = `
`; diff --git a/packages/react-native-web/src/exports/createElement/__tests__/index-test.js b/packages/react-native-web/src/exports/createElement/__tests__/index-test.js index 4d4b95562..47af19b76 100644 --- a/packages/react-native-web/src/exports/createElement/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/createElement/__tests__/index-test.js @@ -12,18 +12,6 @@ describe('modules/createElement', () => { expect(component).toMatchSnapshot(); }); - test('normalizes event.nativeEvent', done => { - const onClick = e => { - e.nativeEvent.timestamp = 1496876171255; - expect(e.nativeEvent).toMatchSnapshot(); - done(); - }; - const component = shallow(createElement('span', { onClick })); - component.find('span').simulate('click', { - nativeEvent: {} - }); - }); - describe('prop "accessibilityRole"', () => { test('and string component type', () => { const component = shallow(createElement('span', { accessibilityRole: 'link' })); diff --git a/packages/react-native-web/src/exports/createElement/index.js b/packages/react-native-web/src/exports/createElement/index.js index d80c4d611..63635e071 100644 --- a/packages/react-native-web/src/exports/createElement/index.js +++ b/packages/react-native-web/src/exports/createElement/index.js @@ -8,83 +8,18 @@ */ import AccessibilityUtil from '../../modules/AccessibilityUtil'; -import { canUseDOM } from 'fbjs/lib/ExecutionEnvironment'; import createDOMProps from '../../modules/createDOMProps'; -import { injectEventPluginsByName } from 'react-dom/unstable-native-dependencies'; -import normalizeNativeEvent from '../../modules/normalizeNativeEvent'; import React from 'react'; -import ResponderEventPlugin from '../../modules/ResponderEventPlugin'; -if (canUseDOM) { - try { - injectEventPluginsByName({ - ResponderEventPlugin - }); - } catch (error) { - // Ignore errors caused by attempting to re-inject the plugin when app - // scripts are being re-evaluated (e.g., development hot reloading) while - // the ReactDOM instance is preserved. - } -} - -const isModifiedEvent = event => - !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); - -/** - * Ensure event handlers receive an event of the expected shape. The 'button' - * role – for accessibility reasons and functional equivalence to the native - * button element – must also support synthetic keyboard activation of onclick, - * and remove event handlers when disabled. - */ -const eventHandlerNames = { - onBlur: true, - onClick: true, - onClickCapture: true, - onContextMenu: true, - onFocus: true, - onResponderRelease: true, - onTouchCancel: true, - onTouchCancelCapture: true, - onTouchEnd: true, - onTouchEndCapture: true, - onTouchMove: true, - onTouchMoveCapture: true, - onTouchStart: true, - onTouchStartCapture: true -}; const adjustProps = domProps => { - const { onClick, onResponderRelease, role } = domProps; + const { onClick, role } = domProps; const isButtonLikeRole = AccessibilityUtil.buttonLikeRoles[role]; const isDisabled = AccessibilityUtil.isDisabled(domProps); - const isLinkRole = role === 'link'; - Object.keys(domProps).forEach(propName => { - const prop = domProps[propName]; - const isEventHandler = typeof prop === 'function' && eventHandlerNames[propName]; - if (isEventHandler) { - if (isButtonLikeRole && isDisabled) { - domProps[propName] = undefined; - } else { - // TODO: move this out of the render path - domProps[propName] = e => { - e.nativeEvent = normalizeNativeEvent(e.nativeEvent); - return prop(e); - }; - } - } - }); - - // Cancel click events if the responder system is being used on a link - // element. Click events are not an expected part of the React Native API, - // and browsers dispatch click events that cannot otherwise be cancelled from - // preceding mouse events in the responder system. - if (isLinkRole && onResponderRelease) { - domProps.onClick = function(e) { - if (!e.isDefaultPrevented() && !isModifiedEvent(e.nativeEvent) && !domProps.target) { - e.preventDefault(); - } - }; + // Button-like roles should not trigger 'onClick' if they are disabled. + if (isButtonLikeRole && isDisabled && domProps.onClick != null) { + domProps.onClick = undefined; } // Button-like roles should trigger 'onClick' if SPACE or ENTER keys are pressed. diff --git a/packages/react-native-web/src/hooks/useResponderEvents/README.md b/packages/react-native-web/src/hooks/useResponderEvents/README.md new file mode 100644 index 000000000..34eef0c49 --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/README.md @@ -0,0 +1,209 @@ +# Responder Event System + +The Responder Event System is a gesture system that manages the lifecycle of gestures. It was designed for [React Native](https://reactnative.dev/docs/next/gesture-responder-system) to help support the development of native-quality gestures. A pointer may transition through several different phases while the gesture is being determined (e.g., tap, scroll, swipe) and be used simultaneously alongside other pointers. The Responder Event System provides a single, global “interaction lock” on views. For a view to become the “responder” means that pointer interactions are exclusive to that view and none other. A view can negotiate to become the “responder” without requiring knowledge of other views. + +NOTE: Although the responder events mention only `touches`, this is for historical reasons (originating from React Native); the system does respond to mouse events which are converted into emulated touches. In the future we could adjust the events to align more with the `PointerEvent` API which would remove this ambiguity and surface more information to developers (e.g., `pointerType`). + +## How it works + +A view can become the "responder" after the following native events: `scroll`, `selectionchange`, `touchstart`, `touchmove`, `mousedown`, `mousemove`. If nothing is already the "responder", the event propagates to (capture) and from (bubble) the event target until a view returns `true` for `on*ShouldSetResponder(Capture)`. + +If something is *already* the responder, the negotiation event propagates to (capture) and from (bubble) the lowest common ancestor of the event target and the current responder. Then negotiation happens between the current responder and the view that wants to become the responder. + +## API + +### useResponderEvents + +The `useResponderEvents` hook takes a ref to a host element and an object of responder callbacks. + +```js +function View(props) { + const hostRef = useRef(null); + + const callbacks: ResponderCallbacks = { + onMoveShouldSetResponder: props.onMoveShouldSetResponder, + onMoveShouldSetResponderCapture: props.onMoveShouldSetResponderCapture, + onResponderEnd: props.onResponderEnd, + onResponderGrant: props.onResponderGrant, + onResponderMove: props.onResponderMove, + onResponderReject: props.onResponderReject, + onResponderRelease: props.onResponderRelease, + onResponderStart: props.onResponderStart, + onResponderTerminate: props.onResponderTerminate, + onResponderTerminationRequest: props.onResponderTerminationRequest, + onScrollShouldSetResponder: props.onScrollShouldSetResponder, + onScrollShouldSetResponderCapture: props.onScrollShouldSetResponderCapture, + onSelectionChangeShouldSetResponder: props.onSelectionChangeShouldSetResponder, + onSelectionChangeShouldSetResponderCapture: props.onSelectionChangeShouldSetResponderCapture, + onStartShouldSetResponder: props.onStartShouldSetResponder, + onStartShouldSetResponderCapture: props.onStartShouldSetResponderCapture + } + + useResponderEvents(hostRef, callbacks); + + return ( +
+ ); +} +``` + +### Responder negotiation + +A view can become the responder by using the negotiation methods. During the capture phase the deepest node is called last. During the bubble phase the deepest node is called first. The capture phase should be used when a view wants to prevent a descendant from becoming the responder. The first view to return `true` from any of the `on*ShouldSetResponderCapture`/`on*ShouldSetResponder` methods will either become the responder or enter into negotiation with the existing responder. + +N.B. If `stopPropagation` is called on the event for any of the negotiation methods, it only stops further negotiation within the Responder System. It will not stop the propagation of the native event (which has already bubbled to the `document` by this time.) + +#### onStartShouldSetResponder / onStartShouldSetResponderCapture + +On pointer down, should this view attempt to become the responder? If the view is not the responder, these methods may be called for every pointer start on the view. + +#### onMoveShouldSetResponder / onMoveShouldSetResponderCapture + +On pointer move, should this view attempt to become the responder? If the view is not the responder, these methods may be called for every pointer move on the view. + +#### onScrollShouldSetResponder / onScrollShouldSetResponderCapture + +On scroll, should this view attempt to become the responder? If the view is not the responder, these methods may be called for every scroll on the view. + +#### onSelectionChangeShouldSetResponder / onSelectionChangeShouldSetResponderCapture + +On text selection change, should this view attempt to become the responder? Does not capture or bubble and is only called on the view that is the first ancestor of the selection `anchorNode`. + +#### onResponderTerminationRequest + +The view is the responder, but another view now wants to become the responder. Should this view release the responder? Returning `true` allows the responder to be released. + +### Responder transfer + +If a view returns `true` for a negotiation method then it will either become the responder (if none exists) or be involved in the responder transfer. The following methods are called only for the views involved in the responder transfer (i.e., no bubbling.) + +#### onResponderGrant + +The view is granted the responder and is now responding to pointer events. The lifecycle methods will be called for this view. This is the point at which you should provide visual feedback for users that the interaction has begun. + +#### onResponderReject + +The view was not granted the responder. It was rejected because another view is already the responder and will not release it. + +#### onResponderTerminate + +The responder has been taken from this view. It may have been taken by another view after a call to `onResponderTerminationRequest`, or it might have been taken by the browser without asking (e.g., window blur, document scroll, context menu open). This is the point at which you should provide visual feedback for users that the interaction has been cancelled. + +### Responder lifecycle + +If a view is the responder, the following methods will be called only for this view (i.e., no bubbling.) These methods are *always* bookended by `onResponderGrant` (before) and either `onResponderRelease` or `onResponderTerminate` (after). + +#### onResponderStart + +A pointer down event occured on the screen. The responder is notified of all start events, even if the pointer target is not this view (i.e., additional pointers are being used). Therefore, this method may be called multiple times while the view is the responder. + +#### onResponderMove + +A pointer move event occured on the screen. The responder is notified of all move events, even if the pointer target is not this view (i.e., additional pointers are being used). Therefore, this method may be called multiple times while the view is the responder. + +#### onResponderEnd + +A pointer up event occured on the screen. The responder is notified of all end events, even if the pointer target is not this view (i.e., additional pointers are being used). Therefore, this method may be called multiple times while the view is the responder. + +#### onResponderRelease + +As soon as there are no more pointers that *started* inside descendants of the responder, this method is called on the responder and the interaction lock is released. This is the point at which you should provide visual feedback for users that the interaction is over. + +### Responder events + +Every method is called with a responder event. The type of the event is shown below. The `currentTarget` of the event is always `null` for the negotiation methods. Data dervied from the native events, e.g., the native `target` and pointer coordinates, can be used to determine the return value of the negotiation methods, etc. + +## Types + +```js +type ResponderCallbacks = { + onResponderEnd?: ?(e: ResponderEvent) => void, + onResponderGrant?: ?(e: ResponderEvent) => void, + onResponderMove?: ?(e: ResponderEvent) => void, + onResponderRelease?: ?(e: ResponderEvent) => void, + onResponderReject?: ?(e: ResponderEvent) => void, + onResponderStart?: ?(e: ResponderEvent) => void, + onResponderTerminate?: ?(e: ResponderEvent) => void, + onResponderTerminationRequest?: ?(e: ResponderEvent) => boolean, + onStartShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onStartShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean, + onMoveShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onMoveShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean, + onScrollShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onScrollShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean, + onSelectionChangeShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onSelectionChangeShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean +}; +``` + +```js +type ResponderEvent = { + // The DOM element acting as the responder view + currentTarget: ?HTMLElement, + defaultPrevented: boolean, + eventPhase: ?number, + isDefaultPrevented: () => boolean, + isPropagationStopped: () => boolean, + isTrusted: boolean, + preventDefault: () => void, + stopPropagation: () => void, + nativeEvent: TouchEvent, + persist: () => void, + target: HTMLElement, + timeStamp: number, + touchHistory: $ReadOnly<{| + indexOfSingleActiveTouch: number, + mostRecentTimeStamp: number, + numberActiveTouches: number, + touchBank: Array<{| + currentPageX: number, + currentPageY: number, + currentTimeStamp: number, + previousPageX: number, + previousPageY: number, + previousTimeStamp: number, + startPageX: number, + startPageY: number, + startTimeStamp: number, + touchActive: boolean + |}> + |}> +}; +``` + +```js +type TouchEvent = { + // Array of all touch events that have changed since the last event + changedTouches: Array, + force: number, + // ID of the touch + identifier: number, + // The X position of the pointer, relative to the currentTarget + locationX: number, + // The Y position of the pointer, relative to the currentTarget + locationY: number, + // The X position of the pointer, relative to the page + pageX: number, + // The Y position of the pointer, relative to the page + pageY: number, + // The DOM element receiving the pointer event + target: HTMLElement, + // A time identifier for the pointer, useful for velocity calculation + timestamp: number, + // Array of all current touches on the screen + touches: Array +}; +``` + +```js +type Touch = { + force: number, + identifier: number, + locationX: number, + locationY: number, + pageX: number, + pageY: number, + target: HTMLElement, + timestamp: number +}; +``` diff --git a/packages/react-native-web/src/hooks/useResponderEvents/ResponderEventTypes.js b/packages/react-native-web/src/hooks/useResponderEvents/ResponderEventTypes.js new file mode 100644 index 000000000..e23f65ba2 --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/ResponderEventTypes.js @@ -0,0 +1,77 @@ +/** + * Copyright (c) Nicolas Gallagher + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export type Touch = { + force: number, + identifier: number, + // The locationX and locationY properties are non-standard additions + locationX: any, + locationY: any, + pageX: number, + pageY: number, + target: any, + // Touches in a list have a timestamp property + timestamp: number +}; + +export type TouchEvent = { + // TouchList is an array in the Responder system + changedTouches: Array, + force: number, + // React Native adds properties to the "nativeEvent that are usually only found on W3C Touches ‾\_(ツ)_/‾ + identifier: number, + locationX: any, + locationY: any, + pageX: number, + pageY: number, + target: any, + // The timestamp has a lowercase "s" in the Responder system + timestamp: number, + // TouchList is an array in the Responder system + touches: Array +}; + +export const BLUR = 'blur'; +export const CONTEXT_MENU = 'contextmenu'; +export const FOCUS_OUT = 'focusout'; +export const MOUSE_DOWN = 'mousedown'; +export const MOUSE_MOVE = 'mousemove'; +export const MOUSE_UP = 'mouseup'; +export const MOUSE_CANCEL = 'dragstart'; +export const TOUCH_START = 'touchstart'; +export const TOUCH_MOVE = 'touchmove'; +export const TOUCH_END = 'touchend'; +export const TOUCH_CANCEL = 'touchcancel'; +export const SCROLL = 'scroll'; +export const SELECT = 'select'; +export const SELECTION_CHANGE = 'selectionchange'; + +export function isStartish(eventType: mixed): boolean { + return eventType === TOUCH_START || eventType === MOUSE_DOWN; +} + +export function isMoveish(eventType: mixed): boolean { + return eventType === TOUCH_MOVE || eventType === MOUSE_MOVE; +} + +export function isEndish(eventType: mixed): boolean { + return eventType === TOUCH_END || eventType === MOUSE_UP || isCancelish(eventType); +} + +export function isCancelish(eventType: mixed): boolean { + return eventType === TOUCH_CANCEL || eventType === MOUSE_CANCEL; +} + +export function isScroll(eventType: mixed): boolean { + return eventType === SCROLL; +} + +export function isSelectionChange(eventType: mixed): boolean { + return eventType === SELECT || eventType === SELECTION_CHANGE; +} diff --git a/packages/react-native-web/src/hooks/useResponderEvents/ResponderSystem.js b/packages/react-native-web/src/hooks/useResponderEvents/ResponderSystem.js new file mode 100644 index 000000000..4f8f1b61e --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/ResponderSystem.js @@ -0,0 +1,627 @@ +/** + * Copyright (c) Nicolas Gallagher + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * RESPONDER EVENT SYSTEM + * + * A single, global "interaction lock" on views. For a view to be the "responder" means + * that pointer interactions are exclusive to that view and none other. The "interaction + * lock" can be transferred (only) to ancestors of the current "responder" as long as + * pointers continue to be active. + * + * Responder being granted: + * + * A view can become the "responder" after the following events: + * * "pointerdown" (implemented using "touchstart", "mousedown") + * * "pointermove" (implemented using "touchmove", "mousemove") + * * "scroll" (while a pointer is down) + * * "selectionchange" (while a pointer is down) + * + * If nothing is already the "responder", the event propagates to (capture) and from + * (bubble) the event target until a view returns `true` for + * `on*ShouldSetResponder(Capture)`. + * + * If something is already the responder, the event propagates to (capture) and from + * (bubble) the lowest common ancestor of the event target and the current "responder". + * Then negotiation happens between the current "responder" and a view that wants to + * become the "responder": see the timing diagram below. + * + * (NOTE: Scrolled views either automatically become the "responder" or release the + * "interaction lock". A native scroll view that isn't built on top of the responder + * system must result in the current "responder" being notified that it no longer has + * the "interaction lock" - the native system has taken over. + * + * Responder being released: + * + * As soon as there are no more active pointers that *started* inside descendants + * of the *current* "responder", an `onResponderRelease` event is dispatched to the + * current "responder", and the responder lock is released. + * + * Typical sequence of events: + * * startShouldSetResponder + * * responderGrant/Reject + * * responderStart + * * responderMove + * * responderEnd + * * responderRelease + */ + +/* Negotiation Performed + +-----------------------+ + / \ +Process low level events to + Current Responder + wantsResponderID +determine who to perform negot-| (if any exists at all) | +iation/transition | Otherwise just pass through| +-------------------------------+----------------------------+------------------+ +Bubble to find first ID | | +to return true:wantsResponderID| | + | | + +--------------+ | | + | onTouchStart | | | + +------+-------+ none | | + | return| | ++-----------v-------------+true| +------------------------+ | +|onStartShouldSetResponder|----->| onResponderStart (cur) |<-----------+ ++-----------+-------------+ | +------------------------+ | | + | | | +--------+-------+ + | returned true for| false:REJECT +-------->|onResponderReject + | wantsResponderID | | | +----------------+ + | (now attempt | +------------------+-----+ | + | handoff) | | onResponder | | + +------------------->| TerminationRequest | | + | +------------------+-----+ | + | | | +----------------+ + | true:GRANT +-------->|onResponderGrant| + | | +--------+-------+ + | +------------------------+ | | + | | onResponderTerminate |<-----------+ + | +------------------+-----+ | + | | | +----------------+ + | +-------->|onResponderStart| + | | +----------------+ +Bubble to find first ID | | +to return true:wantsResponderID| | + | | + +-------------+ | | + | onTouchMove | | | + +------+------+ none | | + | return| | ++-----------v-------------+true| +------------------------+ | +|onMoveShouldSetResponder |----->| onResponderMove (cur) |<-----------+ ++-----------+-------------+ | +------------------------+ | | + | | | +--------+-------+ + | returned true for| false:REJECT +-------->|onResponderReject + | wantsResponderID | | | +----------------+ + | (now attempt | +------------------+-----+ | + | handoff) | | onResponder | | + +------------------->| TerminationRequest| | + | +------------------+-----+ | + | | | +----------------+ + | true:GRANT +-------->|onResponderGrant| + | | +--------+-------+ + | +------------------------+ | | + | | onResponderTerminate |<-----------+ + | +------------------+-----+ | + | | | +----------------+ + | +-------->|onResponderMove | + | | +----------------+ + | | + | | + Some active touch started| | + inside current responder | +------------------------+ | + +------------------------->| onResponderEnd | | + | | +------------------------+ | + +---+---------+ | | + | onTouchEnd | | | + +---+---------+ | | + | | +------------------------+ | + +------------------------->| onResponderEnd | | + No active touches started| +-----------+------------+ | + inside current responder | | | + | v | + | +------------------------+ | + | | onResponderRelease | | + | +------------------------+ | + | | + + + */ + +import type { ResponderEvent } from './createResponderEvent'; + +import createResponderEvent from './createResponderEvent'; +import { + isCancelish, + isEndish, + isMoveish, + isScroll, + isSelectionChange, + isStartish +} from './ResponderEventTypes'; +import { + getLowestCommonAncestor, + getResponderPaths, + hasTargetTouches, + hasValidSelection, + isPrimaryPointerDown, + setResponderId +} from './utils'; +import ResponderTouchHistoryStore from './ResponderTouchHistoryStore'; + +/* ------------ TYPES ------------ */ + +type ResponderId = number; + +type ActiveResponderInstance = { + id: ResponderId, + idPath: Array, + node: any +}; + +type EmptyResponderInstance = { + id: null, + idPath: null, + node: null +}; + +type ResponderInstance = ActiveResponderInstance | EmptyResponderInstance; + +export type ResponderCallbacks = { + // Direct responder events dispatched directly to responder. Do not bubble. + onResponderEnd?: ?(e: ResponderEvent) => void, + onResponderGrant?: ?(e: ResponderEvent) => void, + onResponderMove?: ?(e: ResponderEvent) => void, + onResponderRelease?: ?(e: ResponderEvent) => void, + onResponderReject?: ?(e: ResponderEvent) => void, + onResponderStart?: ?(e: ResponderEvent) => void, + onResponderTerminate?: ?(e: ResponderEvent) => void, + onResponderTerminationRequest?: ?(e: ResponderEvent) => boolean, + // On pointer down, should this element become the responder? + onStartShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onStartShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean, + // On pointer move, should this element become the responder? + onMoveShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onMoveShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean, + // On scroll, should this element become the responder? Do no bubble + onScrollShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onScrollShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean, + // On text selection change, should this element become the responder? + onSelectionChangeShouldSetResponder?: ?(e: ResponderEvent) => boolean, + onSelectionChangeShouldSetResponderCapture?: ?(e: ResponderEvent) => boolean +}; + +const emptyObject = {}; + +/* ------------ IMPLEMENTATION ------------ */ + +const startRegistration = [ + 'onStartShouldSetResponderCapture', + 'onStartShouldSetResponder', + { bubbles: true } +]; +const moveRegistration = [ + 'onMoveShouldSetResponderCapture', + 'onMoveShouldSetResponder', + { bubbles: true } +]; +const scrollRegistration = [ + 'onScrollShouldSetResponderCapture', + 'onScrollShouldSetResponder', + { bubbles: false } +]; +const shouldSetResponderEvents = { + touchstart: startRegistration, + mousedown: startRegistration, + touchmove: moveRegistration, + mousemove: moveRegistration, + scroll: scrollRegistration +}; + +const emptyResponder = { id: null, idPath: null, node: null }; +const responderListenersMap = new Map(); + +let isEmulatingMouseEvents = false; +let trackedTouchCount = 0; +let currentResponder: ResponderInstance = { + id: null, + node: null, + idPath: null +}; + +function changeCurrentResponder(responder: ResponderInstance) { + currentResponder = responder; +} + +function getResponderCallbacks(id: ResponderId): ResponderCallbacks | Object { + const callbacks = responderListenersMap.get(id); + return callbacks != null ? callbacks : emptyObject; +} + +/** + * Process native events + * + * A single event listener is used to manage the responder system. + * All pointers are tracked in the ResponderTouchHistoryStore. Native events + * are interpreted in terms of the Responder System and checked to see if + * the responder should be transferred. Each host node that is attached to + * the Responder System has an ID, which is used to look up its associated + * callbacks. + */ +function eventListener(domEvent: any) { + const eventType = domEvent.type; + const eventTarget = domEvent.target; + + /** + * Manage emulated events and early bailout. + * Since PointerEvent is not used yet (lack of support in older Safari), it's + * necessary to manually manage the mess of browser touch/mouse events. + * And bailout early for termination events when there is no active responder. + */ + + // Flag when browser may produce emulated events + if (eventType === 'touchstart') { + isEmulatingMouseEvents = true; + } + // Remove flag when browser will not produce emulated events + if (eventType === 'touchmove' || trackedTouchCount > 1) { + isEmulatingMouseEvents = false; + } + // Ignore various events in particular circumstances + if ( + // Ignore browser emulated mouse events + (eventType === 'mousedown' && isEmulatingMouseEvents) || + (eventType === 'mousemove' && isEmulatingMouseEvents) || + // Ignore mousemove if a mousedown didn't occur first + (eventType === 'mousemove' && trackedTouchCount < 1) + ) { + return; + } + // Remove flag after emulated events are finished + if (isEmulatingMouseEvents && eventType === 'mouseup') { + if (trackedTouchCount === 0) { + isEmulatingMouseEvents = false; + } + return; + } + + const isStartEvent = isStartish(eventType) && isPrimaryPointerDown(domEvent); + const isMoveEvent = isMoveish(eventType); + const isEndEvent = isEndish(eventType); + const isScrollEvent = isScroll(eventType); + const isSelectionChangeEvent = isSelectionChange(eventType); + const responderEvent = createResponderEvent(domEvent); + + /** + * Record the state of active pointers + */ + + if (isStartEvent || isMoveEvent || isEndEvent) { + if (domEvent.touches) { + trackedTouchCount = domEvent.touches.length; + } else { + if (isStartEvent) { + trackedTouchCount = 1; + } else if (isEndEvent) { + trackedTouchCount = 0; + } + } + ResponderTouchHistoryStore.recordTouchTrack(eventType, responderEvent.nativeEvent); + } + + /** + * Responder System logic + */ + + let eventPaths = getResponderPaths(domEvent); + let wasNegotiated = false; + let wantsResponder; + + // If an event occured that might change the current responder... + if (isStartEvent || isMoveEvent || (isScrollEvent && trackedTouchCount > 0)) { + // If there is already a responder, prune the event paths to the lowest common ancestor + // of the existing responder and deepest target of the event. + const currentResponderIdPath = currentResponder.idPath; + const eventIdPath = eventPaths.idPath; + + if (currentResponderIdPath != null && eventIdPath != null) { + const lowestCommonAncestor = getLowestCommonAncestor(currentResponderIdPath, eventIdPath); + const indexOfLowestCommonAncestor = eventIdPath.indexOf(lowestCommonAncestor); + // Skip the current responder so it doesn't receive unexpected "shouldSet" events. + const index = + indexOfLowestCommonAncestor + (lowestCommonAncestor === currentResponder.id ? 1 : 0); + eventPaths = { + idPath: eventIdPath.slice(index), + nodePath: eventPaths.nodePath.slice(index) + }; + } + // If a node wants to become the responder, attempt to transfer. + wantsResponder = findWantsResponder(eventPaths, domEvent, responderEvent); + if (wantsResponder != null) { + // Sets responder if none exists, or negotates with existing responder. + attemptTransfer(responderEvent, wantsResponder); + wasNegotiated = true; + } + } + + // If there is now a responder, invoke its callbacks for the lifecycle of the gesture. + if (currentResponder.id != null && currentResponder.node != null) { + const { id, node } = currentResponder; + const { + onResponderStart, + onResponderMove, + onResponderEnd, + onResponderRelease, + onResponderTerminate, + onResponderTerminationRequest + } = getResponderCallbacks(id); + + responderEvent.currentTarget = node; + + // Start + if (isStartEvent) { + if (onResponderStart != null) { + onResponderStart(responderEvent); + } + } + // Move + else if (isMoveEvent) { + if (onResponderMove != null) { + onResponderMove(responderEvent); + } + } else { + const isTerminateEvent = + isCancelish(eventType) || + // native context menu + eventType === 'contextmenu' || + // responder (or parents including window) blur + (eventType === 'blur' && (domEvent.target === window || domEvent.target.contains(node))) || + // native scroll without using a pointer + (isScrollEvent && trackedTouchCount === 0) || + // native scroll on node that is parent of the responder (allow siblings to scroll) + (isScrollEvent && eventTarget.contains(node) && eventTarget !== node) || + // native select/selectionchange on node + (isSelectionChangeEvent && hasValidSelection(domEvent)); + + const isReleaseEvent = + isEndEvent && !isTerminateEvent && !hasTargetTouches(node, domEvent.touches); + + // End + if (isEndEvent) { + if (onResponderEnd != null) { + onResponderEnd(responderEvent); + } + } + // Release + if (isReleaseEvent) { + if (onResponderRelease != null) { + onResponderRelease(responderEvent); + } + changeCurrentResponder(emptyResponder); + } + // Terminate + if (isTerminateEvent) { + let shouldTerminate = true; + + // Responders can still avoid termination but only for scroll events. + if (eventType === 'scroll') { + if ( + wasNegotiated || + // Only call this function is it wasn't already called during negotiation. + (onResponderTerminationRequest != null && + onResponderTerminationRequest(responderEvent) === false) + ) { + shouldTerminate = false; + } + } + + if (shouldTerminate) { + if (onResponderTerminate != null) { + onResponderTerminate(responderEvent); + } + changeCurrentResponder(emptyResponder); + isEmulatingMouseEvents = false; + trackedTouchCount = 0; + } + } + } + } +} + +/** + * Walk the event path to/from the target node. At each node, stop and call the + * relevant "shouldSet" functions for the given event type. If any of those functions + * call "stopPropagation" on the event, stop searching for a responder. + */ +function findWantsResponder(eventPaths, domEvent, responderEvent) { + const shouldSetCallbacks = shouldSetResponderEvents[(domEvent.type: any)]; // for Flow + + if (shouldSetCallbacks != null) { + const { idPath, nodePath } = eventPaths; + + const shouldSetCallbackCaptureName = shouldSetCallbacks[0]; + const shouldSetCallbackBubbleName = shouldSetCallbacks[1]; + const { bubbles } = shouldSetCallbacks[2]; + + const check = function(id, node, callbackName) { + const callbacks = getResponderCallbacks(id); + const shouldSetCallback = callbacks[callbackName]; + if (shouldSetCallback != null) { + if (shouldSetCallback(responderEvent) === true) { + return { id, node, idPath }; + } + } + }; + + // capture + for (let i = idPath.length - 1; i >= 0; i--) { + const id = idPath[i]; + const node = nodePath[i]; + const result = check(id, node, shouldSetCallbackCaptureName); + if (result != null) { + return result; + } + if (responderEvent.isPropagationStopped() === true) { + return; + } + } + + // bubble + if (bubbles) { + for (let i = 0; i < idPath.length; i++) { + const id = idPath[i]; + const node = nodePath[i]; + const result = check(id, node, shouldSetCallbackBubbleName); + if (result != null) { + return result; + } + if (responderEvent.isPropagationStopped() === true) { + return; + } + } + } else { + const id = idPath[0]; + const node = nodePath[0]; + const target = domEvent.target; + if (target === node) { + return check(id, node, shouldSetCallbackBubbleName); + } + } + } +} + +/** + * Attempt to transfer the responder. + */ +function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveResponderInstance) { + const { id: currentId, node: currentNode } = currentResponder; + const { id, node } = wantsResponder; + + const { onResponderGrant, onResponderReject } = getResponderCallbacks(id); + + // Set responder + if (currentId == null) { + if (onResponderGrant != null) { + responderEvent.currentTarget = node; + onResponderGrant(responderEvent); + } + changeCurrentResponder(wantsResponder); + } + // Negotiate with current responder + else { + const { onResponderTerminate, onResponderTerminationRequest } = getResponderCallbacks( + currentId + ); + const allowTransfer = + onResponderTerminationRequest != null && onResponderTerminationRequest(responderEvent); + if (allowTransfer) { + // Terminate existing responder + if (onResponderTerminate != null) { + responderEvent.currentTarget = currentNode; + onResponderTerminate(responderEvent); + } + // Grant next responder + if (onResponderGrant != null) { + responderEvent.currentTarget = node; + onResponderGrant(responderEvent); + } + changeCurrentResponder(wantsResponder); + } else { + // Reject responder request + if (onResponderReject != null) { + responderEvent.currentTarget = node; + onResponderReject(responderEvent); + } + } + } +} + +/* ------------ PUBLIC API ------------ */ + +/** + * Attach Listeners + * + * Use native events as ReactDOM doesn't have a non-plugin API to implement + * this system. + */ +const documentEvents = [ + // mouse + 'mousedown', + 'mousemove', + 'mouseup', + 'dragstart', + // touch + 'touchstart', + 'touchmove', + 'touchend', + 'touchcancel', + // other + 'contextmenu', + 'blur', + 'scroll', + 'select', + 'selectionchange' +]; +export function attachListeners() { + if (window.__reactResponderSystemActive == null) { + window.addEventListener('blur', eventListener); + documentEvents.forEach(eventType => { + // Use 'capture' phase so responder listeners are called before listeners attached by ReactDOM. + // This preserves the expected order of ResponderEventPlugin vs SimpleEventPlugin. + // NOTE: The responder system never stops propagation of native events. + document.addEventListener(eventType, eventListener, true); + }); + window.__reactResponderSystemActive = true; + } +} + +/** + * Register a node with the ResponderSystem. + */ +export function addNode(id: ResponderId, node: any, callbacks: ResponderCallbacks) { + setResponderId(node, id); + responderListenersMap.set(id, callbacks); +} + +/** + * Unregister a node with the ResponderSystem. + */ +export function removeNode(id: ResponderId) { + if (currentResponder.id === id) { + terminateResponder(); + } + if (responderListenersMap.has(id)) { + responderListenersMap.delete(id); + } +} + +/** + * Allow the current responder to be terminated from within components to support + * more complex requirements, such as use with other React libraries for working + * with scroll views, input views, etc. + */ +export function terminateResponder() { + const { id, node } = currentResponder; + if (id != null && node != null) { + const { onResponderTerminate } = getResponderCallbacks(id); + if (onResponderTerminate != null) { + const event = createResponderEvent({}); + event.currentTarget = node; + onResponderTerminate(event); + } + changeCurrentResponder(emptyResponder); + } + isEmulatingMouseEvents = false; + trackedTouchCount = 0; +} + +/** + * Allow unit tests to inspect the current responder in the system. + * FOR TESTING ONLY. + */ +export function getResponderNode(): any { + return currentResponder.node; +} diff --git a/packages/react-native-web/src/hooks/useResponderEvents/ResponderTouchHistoryStore.js b/packages/react-native-web/src/hooks/useResponderEvents/ResponderTouchHistoryStore.js new file mode 100644 index 000000000..07d97dc02 --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/ResponderTouchHistoryStore.js @@ -0,0 +1,202 @@ +/** + * 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 { Touch, TouchEvent } from './ResponderEventTypes'; +import { isStartish, isMoveish, isEndish } from './ResponderEventTypes'; + +type TouchRecord = {| + touchActive: boolean, + startPageX: number, + startPageY: number, + startTimeStamp: number, + currentPageX: number, + currentPageY: number, + currentTimeStamp: number, + previousPageX: number, + previousPageY: number, + previousTimeStamp: number +|}; + +/** + * Tracks the position and time of each active touch by `touch.identifier`. We + * should typically only see IDs in the range of 1-20 because IDs get recycled + * when touches end and start again. + */ + +const __DEV__ = process.env.NODE_ENV !== 'production'; +const MAX_TOUCH_BANK = 20; +const touchBank: Array = []; +const touchHistory = { + touchBank, + numberActiveTouches: 0, + // If there is only one active touch, we remember its location. This prevents + // us having to loop through all of the touches all the time in the most + // common case. + indexOfSingleActiveTouch: -1, + mostRecentTimeStamp: 0 +}; + +function timestampForTouch(touch: Touch): number { + // The legacy internal implementation provides "timeStamp", which has been + // renamed to "timestamp". + return (touch: any).timeStamp || touch.timestamp; +} + +/** + * TODO: Instead of making gestures recompute filtered velocity, we could + * include a built in velocity computation that can be reused globally. + */ +function createTouchRecord(touch: Touch): TouchRecord { + return { + touchActive: true, + startPageX: touch.pageX, + startPageY: touch.pageY, + startTimeStamp: timestampForTouch(touch), + currentPageX: touch.pageX, + currentPageY: touch.pageY, + currentTimeStamp: timestampForTouch(touch), + previousPageX: touch.pageX, + previousPageY: touch.pageY, + previousTimeStamp: timestampForTouch(touch) + }; +} + +function resetTouchRecord(touchRecord: TouchRecord, touch: Touch): void { + touchRecord.touchActive = true; + touchRecord.startPageX = touch.pageX; + touchRecord.startPageY = touch.pageY; + touchRecord.startTimeStamp = timestampForTouch(touch); + touchRecord.currentPageX = touch.pageX; + touchRecord.currentPageY = touch.pageY; + touchRecord.currentTimeStamp = timestampForTouch(touch); + touchRecord.previousPageX = touch.pageX; + touchRecord.previousPageY = touch.pageY; + touchRecord.previousTimeStamp = timestampForTouch(touch); +} + +function getTouchIdentifier({ identifier }: Touch): number { + if (identifier == null) { + console.error('Touch object is missing identifier.'); + } + if (__DEV__) { + if (identifier > MAX_TOUCH_BANK) { + console.error( + 'Touch identifier %s is greater than maximum supported %s which causes ' + + 'performance issues backfilling array locations for all of the indices.', + identifier, + MAX_TOUCH_BANK + ); + } + } + return identifier; +} + +function recordTouchStart(touch: Touch): void { + const identifier = getTouchIdentifier(touch); + const touchRecord = touchBank[identifier]; + if (touchRecord) { + resetTouchRecord(touchRecord, touch); + } else { + touchBank[identifier] = createTouchRecord(touch); + } + touchHistory.mostRecentTimeStamp = timestampForTouch(touch); +} + +function recordTouchMove(touch: Touch): void { + const touchRecord = touchBank[getTouchIdentifier(touch)]; + if (touchRecord) { + touchRecord.touchActive = true; + touchRecord.previousPageX = touchRecord.currentPageX; + touchRecord.previousPageY = touchRecord.currentPageY; + touchRecord.previousTimeStamp = touchRecord.currentTimeStamp; + touchRecord.currentPageX = touch.pageX; + touchRecord.currentPageY = touch.pageY; + touchRecord.currentTimeStamp = timestampForTouch(touch); + touchHistory.mostRecentTimeStamp = timestampForTouch(touch); + } else { + console.warn( + 'Cannot record touch move without a touch start.\n', + `Touch Move: ${printTouch(touch)}\n`, + `Touch Bank: ${printTouchBank()}` + ); + } +} + +function recordTouchEnd(touch: Touch): void { + const touchRecord = touchBank[getTouchIdentifier(touch)]; + if (touchRecord) { + touchRecord.touchActive = false; + touchRecord.previousPageX = touchRecord.currentPageX; + touchRecord.previousPageY = touchRecord.currentPageY; + touchRecord.previousTimeStamp = touchRecord.currentTimeStamp; + touchRecord.currentPageX = touch.pageX; + touchRecord.currentPageY = touch.pageY; + touchRecord.currentTimeStamp = timestampForTouch(touch); + touchHistory.mostRecentTimeStamp = timestampForTouch(touch); + } else { + console.warn( + 'Cannot record touch end without a touch start.\n', + `Touch End: ${printTouch(touch)}\n`, + `Touch Bank: ${printTouchBank()}` + ); + } +} + +function printTouch(touch: Touch): string { + return JSON.stringify({ + identifier: touch.identifier, + pageX: touch.pageX, + pageY: touch.pageY, + timestamp: timestampForTouch(touch) + }); +} + +function printTouchBank(): string { + let printed = JSON.stringify(touchBank.slice(0, MAX_TOUCH_BANK)); + if (touchBank.length > MAX_TOUCH_BANK) { + printed += ' (original size: ' + touchBank.length + ')'; + } + return printed; +} + +const ResponderTouchHistoryStore = { + recordTouchTrack(topLevelType: string, nativeEvent: TouchEvent): void { + if (isMoveish(topLevelType)) { + nativeEvent.changedTouches.forEach(recordTouchMove); + } else if (isStartish(topLevelType)) { + nativeEvent.changedTouches.forEach(recordTouchStart); + touchHistory.numberActiveTouches = nativeEvent.touches.length; + if (touchHistory.numberActiveTouches === 1) { + touchHistory.indexOfSingleActiveTouch = nativeEvent.touches[0].identifier; + } + } else if (isEndish(topLevelType)) { + nativeEvent.changedTouches.forEach(recordTouchEnd); + touchHistory.numberActiveTouches = nativeEvent.touches.length; + if (touchHistory.numberActiveTouches === 1) { + for (let i = 0; i < touchBank.length; i++) { + const touchTrackToCheck = touchBank[i]; + if (touchTrackToCheck != null && touchTrackToCheck.touchActive) { + touchHistory.indexOfSingleActiveTouch = i; + break; + } + } + if (__DEV__) { + const activeRecord = touchBank[touchHistory.indexOfSingleActiveTouch]; + if (!(activeRecord != null && activeRecord.touchActive)) { + console.error('Cannot find single active touch.'); + } + } + } + } + }, + + touchHistory +}; + +export default ResponderTouchHistoryStore; diff --git a/packages/react-native-web/src/hooks/useResponderEvents/__tests__/index-test.js b/packages/react-native-web/src/hooks/useResponderEvents/__tests__/index-test.js new file mode 100644 index 000000000..ffdda4d4a --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/__tests__/index-test.js @@ -0,0 +1,2443 @@ +/* eslint-env jasmine, jest */ + +import { act } from 'react-dom/test-utils'; +import React, { createRef } from 'react'; +import ReactDOM from 'react-dom'; +import useResponderEvents from '..'; +import { getResponderNode, terminateResponder } from '../ResponderSystem'; +import { + buttonType, + buttonsType, + clearPointers, + createEventTarget, + testWithPointerType +} from 'dom-event-testing-library'; + +describe('useResponderEvents', () => { + let container; + + function render(element) { + ReactDOM.render(element, container); + } + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + render(null); + document.body.removeChild(container); + container = null; + // make sure all tests end with the current responder being reset + terminateResponder(); + // make sure all tests reset state machine tracking pointers on the mock surface + clearPointers(); + }); + + testWithPointerType('does nothing when no elements want to respond', pointerType => { + const targetRef = createRef(); + const Component = () => { + useResponderEvents(targetRef, { + onStartShouldSetResponder: jest.fn() + }); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture + act(() => { + target.pointerdown({ pointerType }); + }); + // no responder should be active + expect(getResponderNode()).toBe(null); + }); + + test('does nothing for "mousedown" with non-primary buttons', () => { + const targetRef = createRef(); + const Component = () => { + useResponderEvents(targetRef, { + onStartShouldSetResponderCapture: jest.fn(() => true), + onStartShouldSetResponder: jest.fn(() => true) + }); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const buttons = [1, 2, 3, 4]; + // gesture + act(() => { + buttons.forEach(button => { + target.pointerdown({ + pointerType: 'mouse', + button: buttonType.auxiliary, + buttons: buttonsType.auxiliary + }); + }); + }); + expect(getResponderNode()).toBe(null); + }); + + test('does nothing for "mousedown" with modifier keys', () => { + const targetRef = createRef(); + const Component = () => { + useResponderEvents(targetRef, { + onStartShouldSetResponderCapture: jest.fn(() => true), + onStartShouldSetResponder: jest.fn(() => true) + }); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const modifierKeys = ['altKey', 'ctrlKey', 'metaKey', 'shiftKey']; + // gesture + act(() => { + modifierKeys.forEach(modifierKey => { + target.pointerdown({ pointerType: 'mouse', [modifierKey]: true }); + }); + }); + expect(getResponderNode()).toBe(null); + }); + + test('recognizes mouse interactions after touch interactions', () => { + const targetRef = createRef(); + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // touch gesture + act(() => { + target.pointerdown({ pointerType: 'touch' }); + target.pointerup({ pointerType: 'touch' }); + }); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + // mouse gesture + act(() => { + target.pointerdown({ pointerType: 'mouse' }); + target.pointerup({ pointerType: 'mouse' }); + }); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(2); + // touch gesture with move + act(() => { + target.pointerdown({ pointerType: 'touch' }); + target.pointermove({ pointerType: 'touch' }); + target.pointerup({ pointerType: 'touch' }); + }); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(3); + // mouse gesture + act(() => { + target.pointerdown({ pointerType: 'mouse' }); + target.pointerup({ pointerType: 'mouse' }); + }); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(4); + }); + + /** + * SET: onStartShouldSetResponderCapture + */ + + describe('onStartShouldSetResponderCapture', () => { + let grandParentRef; + let parentRef; + let targetRef; + + beforeEach(() => { + grandParentRef = createRef(); + parentRef = createRef(); + targetRef = createRef(); + }); + + testWithPointerType('start grants responder to grandParent', pointerType => { + let grantCurrentTarget, shouldSetCurrentTarget; + const grandParentCallbacks = { + onStartShouldSetResponderCapture: jest.fn(e => { + shouldSetCurrentTarget = e.currentTarget; + return true; + }), + onResponderGrant: jest.fn(e => { + grantCurrentTarget = e.currentTarget; + }) + }; + const parentCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => true) + }; + const targetCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder set (capture phase) + expect(grandParentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); + expect(shouldSetCurrentTarget).toBe(null); + expect(parentCallbacks.onStartShouldSetResponderCapture).not.toBeCalled(); + expect(targetCallbacks.onStartShouldSetResponderCapture).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(grandParentRef.current); + expect(grandParentCallbacks.onResponderGrant).toBeCalledTimes(1); + expect(grantCurrentTarget).toBe(grandParentRef.current); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('start grants responder to parent', pointerType => { + const grandParentCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => false) + }; + const parentCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const targetCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder set (capture phase) + expect(grandParentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); + expect(parentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); + expect(targetCallbacks.onStartShouldSetResponderCapture).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(parentRef.current); + expect(parentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('start grants responder to child', pointerType => { + const grandParentCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => false) + }; + const parentCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => false) + }; + const targetCallbacks = { + onStartShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder set (capture phase) + expect(grandParentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); + expect(parentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); + expect(targetCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); + // responder grant + expect(getResponderNode()).toBe(targetRef.current); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + }); + + /** + * SET: onStartShouldSetResponder + */ + + describe('onStartShouldSetResponder', () => { + let targetRef; + let parentRef; + let grandParentRef; + + beforeEach(() => { + targetRef = createRef(); + parentRef = createRef(); + grandParentRef = createRef(); + }); + + testWithPointerType('start grants responder to child', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const parentCallbacks = { + onStartShouldSetResponder: jest.fn(() => true) + }; + const grandParentCallbacks = { + onStartShouldSetResponder: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder set (bubble phase) + expect(targetCallbacks.onStartShouldSetResponder).toBeCalledTimes(1); + expect(parentCallbacks.onStartShouldSetResponder).not.toBeCalled(); + expect(grandParentCallbacks.onStartShouldSetResponder).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(targetRef.current); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('start grants responder to parent', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => false) + }; + const parentCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const grandParentCallbacks = { + onStartShouldSetResponder: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder set (bubble phase) + expect(targetCallbacks.onStartShouldSetResponder).toBeCalledTimes(1); + expect(parentCallbacks.onStartShouldSetResponder).toBeCalledTimes(1); + expect(grandParentCallbacks.onStartShouldSetResponder).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(parentRef.current); + expect(parentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('start grants responder to grandParent', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => false) + }; + const parentCallbacks = { + onStartShouldSetResponder: jest.fn(() => false) + }; + const grandParentCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder set (bubble phase) + expect(targetCallbacks.onStartShouldSetResponder).toBeCalledTimes(1); + expect(parentCallbacks.onStartShouldSetResponder).toBeCalledTimes(1); + expect(grandParentCallbacks.onStartShouldSetResponder).toBeCalledTimes(1); + // responder grant + expect(getResponderNode()).toBe(grandParentRef.current); + expect(grandParentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + }); + + /** + * SET: onMoveShouldSetResponderCapture + */ + + describe('onMoveShouldSetResponderCapture', () => { + let grandParentRef; + let parentRef; + let targetRef; + + beforeEach(() => { + grandParentRef = createRef(); + parentRef = createRef(); + targetRef = createRef(); + }); + + testWithPointerType('move grants responder to grandParent', pointerType => { + const grandParentCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const parentCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => true) + }; + const targetCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder set (capture phase) + expect(grandParentCallbacks.onMoveShouldSetResponderCapture).toBeCalledTimes(1); + expect(parentCallbacks.onMoveShouldSetResponderCapture).not.toBeCalled(); + expect(targetCallbacks.onMoveShouldSetResponderCapture).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(grandParentRef.current); + expect(grandParentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('move grants responder to parent', pointerType => { + const grandParentCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => false) + }; + const parentCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const targetCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder set (capture phase) + expect(grandParentCallbacks.onMoveShouldSetResponderCapture).toBeCalledTimes(1); + expect(parentCallbacks.onMoveShouldSetResponderCapture).toBeCalledTimes(1); + expect(targetCallbacks.onMoveShouldSetResponderCapture).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(parentRef.current); + expect(parentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('move grants responder to child', pointerType => { + const grandParentCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => false) + }; + const parentCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => false) + }; + const targetCallbacks = { + onMoveShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder set (capture phase) + expect(grandParentCallbacks.onMoveShouldSetResponderCapture).toBeCalledTimes(1); + expect(parentCallbacks.onMoveShouldSetResponderCapture).toBeCalledTimes(1); + expect(targetCallbacks.onMoveShouldSetResponderCapture).toBeCalledTimes(1); + // responder grant + expect(getResponderNode()).toBe(targetRef.current); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + }); + + /** + * SET: onMoveShouldSetResponder + */ + + describe('onMoveShouldSetResponder', () => { + let targetRef; + let parentRef; + let grandParentRef; + + beforeEach(() => { + targetRef = createRef(); + parentRef = createRef(); + grandParentRef = createRef(); + }); + + testWithPointerType('move grants responder to child', pointerType => { + const targetCallbacks = { + onMoveShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const parentCallbacks = { + onMoveShouldSetResponder: jest.fn(() => true) + }; + const grandParentCallbacks = { + onMoveShouldSetResponder: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder set (bubble phase) + expect(targetCallbacks.onMoveShouldSetResponder).toBeCalledTimes(1); + expect(parentCallbacks.onMoveShouldSetResponder).not.toBeCalled(); + expect(grandParentCallbacks.onMoveShouldSetResponder).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(targetRef.current); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('move grants responder to parent', pointerType => { + const targetCallbacks = { + onMoveShouldSetResponder: jest.fn(() => false) + }; + const parentCallbacks = { + onMoveShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const grandParentCallbacks = { + onMoveShouldSetResponder: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder set (bubble phase) + expect(targetCallbacks.onMoveShouldSetResponder).toBeCalledTimes(1); + expect(parentCallbacks.onMoveShouldSetResponder).toBeCalledTimes(1); + expect(grandParentCallbacks.onMoveShouldSetResponder).not.toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(parentRef.current); + expect(parentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('move grants responder to grandParent', pointerType => { + const targetCallbacks = { + onMoveShouldSetResponder: jest.fn(() => false) + }; + const parentCallbacks = { + onMoveShouldSetResponder: jest.fn(() => false) + }; + const grandParentCallbacks = { + onMoveShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder set (bubble phase) + expect(targetCallbacks.onMoveShouldSetResponder).toBeCalled(); + expect(parentCallbacks.onMoveShouldSetResponder).toBeCalled(); + expect(grandParentCallbacks.onMoveShouldSetResponder).toBeCalled(); + // responder grant + expect(getResponderNode()).toBe(grandParentRef.current); + expect(grandParentCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + }); + + /** + * SET: onScrollShouldSetResponderCapture + */ + + describe('onScrollShouldSetResponderCapture', () => { + let targetRef; + let parentRef; + + beforeEach(() => { + targetRef = createRef(); + parentRef = createRef(); + }); + + testWithPointerType('scroll grants responder to parent if a pointer is down', pointerType => { + const parentCallbacks = { + onScrollShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + const targetCallbacks = { + onScrollShouldSetResponderCapture: jest.fn(() => false) + }; + + const Component = () => { + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + target.scroll(); + }); + // responder set (capture phase) + expect(parentCallbacks.onScrollShouldSetResponderCapture).toBeCalledTimes(1); + expect(targetCallbacks.onScrollShouldSetResponderCapture).toBeCalledTimes(0); + // responder grant + expect(getResponderNode()).toBe(parentRef.current); + expect(parentCallbacks.onResponderGrant).toBeCalledTimes(1); + }); + + testWithPointerType('scroll grants responder to target if a pointer is down', pointerType => { + const parentCallbacks = { + onScrollShouldSetResponderCapture: jest.fn(() => false) + }; + const targetCallbacks = { + onScrollShouldSetResponderCapture: jest.fn(() => true), + onResponderGrant: jest.fn() + }; + + const Component = () => { + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + target.scroll(); + }); + // responder set (capture phase) + expect(parentCallbacks.onScrollShouldSetResponderCapture).toBeCalledTimes(1); + expect(targetCallbacks.onScrollShouldSetResponderCapture).toBeCalledTimes(1); + // responder grant + expect(getResponderNode()).toBe(targetRef.current); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + }); + }); + + /** + * SET: onScrollShouldSetResponder + */ + + describe('onScrollShouldSetResponder', () => { + let targetRef; + let parentRef; + + beforeEach(() => { + targetRef = createRef(); + parentRef = createRef(); + }); + + test('scroll does not bubble to parent', () => { + const parentCallbacks = { + onScrollShouldSetResponder: jest.fn(() => true) + }; + + const Component = () => { + useResponderEvents(parentRef, parentCallbacks); + return ( +
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown(); + target.scroll(); + }); + // no bubble + expect(parentCallbacks.onScrollShouldSetResponder).toBeCalledTimes(0); + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('scroll grants responder to target if a pointer is down', pointerType => { + const targetCallbacks = { + onScrollShouldSetResponder: jest.fn(() => true), + onResponderGrant: jest.fn(), + onResponderRelease: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + target.scroll(); + target.scroll(); + }); + // responder set (bubble phase) + expect(targetCallbacks.onScrollShouldSetResponder).toBeCalledTimes(1); + // responder grant + expect(getResponderNode()).toBe(targetRef.current); + expect(targetCallbacks.onResponderGrant).toBeCalledTimes(1); + // gesture end + act(() => { + target.pointerup({ pointerType }); + }); + // make sure release is called + expect(getResponderNode()).toBe(null); + expect(targetCallbacks.onResponderRelease).toBeCalledTimes(1); + }); + }); + + /** + * SET: onSelectionChangeShouldSetResponderCapture + * Not implemented. Expected behevior is not clear. Always terminate the responder + * and let the native system take over. + */ + + describe.skip('onSelectionChangeShouldSetResponderCapture', () => {}); + + /** + * SET: onSelectionChangeShouldSetResponder + * Not implemented. Expected behevior is not clear. Always terminate the responder + * and let the native system take over. + */ + + describe.skip('onSelectionChangeShouldSetResponder', () => {}); + + /** + * onResponderStart + */ + + describe('onResponderStart', () => { + let targetRef; + + beforeEach(() => { + targetRef = createRef(); + }); + + testWithPointerType( + 'is called after "start" event on the view that became the responder', + pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderStart: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start + act(() => { + target.pointerdown({ pointerType }); + }); + // responder start + expect(targetCallbacks.onResponderStart).toBeCalledTimes(1); + expect(targetCallbacks.onResponderStart).toBeCalledWith( + expect.objectContaining({ + currentTarget: targetRef.current + }) + ); + } + ); + }); + + /** + * onResponderMove + */ + + describe('onResponderMove', () => { + let targetRef; + + beforeEach(() => { + targetRef = createRef(); + }); + + // Assert that 'onResponderMove' after a move event, is called however the responder became active + ['onStartShouldSetResponder', 'onMoveShouldSetResponder'].forEach(shouldSetResponder => { + testWithPointerType( + `is called after "move" event on responder (${shouldSetResponder})`, + pointerType => { + const targetCallbacks = { + [shouldSetResponder]: jest.fn(() => true), + onResponderMove: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & move + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + }); + // responder move + expect(targetCallbacks.onResponderMove).toBeCalledTimes(1); + expect(targetCallbacks.onResponderMove).toBeCalledWith( + expect.objectContaining({ + currentTarget: targetRef.current + }) + ); + } + ); + }); + }); + + /** + * onResponderEnd + */ + + describe('onResponderEnd', () => { + let targetRef; + + beforeEach(() => { + targetRef = createRef(); + }); + + testWithPointerType('is called after "end" event on responder', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderEnd: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & end + act(() => { + target.pointerdown({ pointerType }); + target.pointerup({ pointerType }); + }); + // responder end + expect(targetCallbacks.onResponderEnd).toBeCalledTimes(1); + expect(targetCallbacks.onResponderEnd).toBeCalledWith( + expect.objectContaining({ + currentTarget: targetRef.current + }) + ); + }); + }); + + /** + * onResponderRelease + */ + + describe('onResponderRelease', () => { + let targetRef; + + beforeEach(() => { + targetRef = createRef(); + }); + + testWithPointerType('is called after all touches with responder end', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderRelease: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture + act(() => { + target.pointerdown({ pointerType, pointerId: 1 }); + target.pointerdown({ pointerType, pointerId: 2 }); + target.pointerup({ pointerType, pointerId: 2 }); + target.pointerup({ pointerType, pointerId: 1 }); + }); + // responder release + expect(targetCallbacks.onResponderRelease).toBeCalledTimes(1); + expect(targetCallbacks.onResponderRelease).toBeCalledWith( + expect.objectContaining({ + currentTarget: targetRef.current + }) + ); + }); + }); + + /** + * onResponderTerminate + */ + + describe('onResponderTerminate', () => { + let targetRef; + + beforeEach(() => { + targetRef = createRef(); + }); + + testWithPointerType('is called if pointer cancels', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderEnd: jest.fn(), + onResponderTerminate: jest.fn(), + onResponderTerminationRequest: jest.fn(() => false) + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // gesture start & cancel + act(() => { + target.pointerdown({ pointerType }); + target.pointercancel({ pointerType }); + }); + // responder terminates + expect(targetCallbacks.onResponderEnd).toBeCalledTimes(1); + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + expect(targetCallbacks.onResponderTerminate).toBeCalledWith( + expect.objectContaining({ + currentTarget: targetRef.current + }) + ); + }); + + testWithPointerType('is called if input "select" occurs', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn(), + onResponderTerminationRequest: jest.fn(() => false) + }; + + const inputRef = createRef(); + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+ +
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const input = createEventTarget(inputRef.current); + // getSelection is not supported in jest + act(() => { + target.pointerdown({ pointerType }); + input.select({}); + }); + // responder terminates + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // responder should not be set + expect(getResponderNode()).toBe(null); + }); + + testWithPointerType('is called if "selectionchange" occurs', pointerType => { + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn(), + onResponderTerminationRequest: jest.fn(() => false) + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
text selection test
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const doc = createEventTarget(document); + // getSelection is not supported in jest + window.getSelection = jest.fn(() => { + const node = targetRef.current; + const anchorNode = node != null && node.firstChild != null ? node.firstChild : node; + return { + anchorNode, + toString() { + return 'text'; + } + }; + }); + act(() => { + target.pointerdown({ pointerType }); + target.pointermove({ pointerType }); + doc.selectionchange({}); + }); + // responder terminates + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // responder should not be set + expect(getResponderNode()).toBe(null); + }); + + test('is called if ancestor scrolls', () => { + const pointerType = 'touch'; + const parentRef = createRef(); + + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const parent = createEventTarget(parentRef.current); + // gesture start & scroll + act(() => { + target.pointerdown({ pointerType }); + // ancestor scrolls + parent.scroll(); + }); + // responder terminates + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + test('is called if document scrolls', () => { + const pointerType = 'touch'; + const parentRef = createRef(); + + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const doc = createEventTarget(document); + // gesture start & scroll + act(() => { + target.pointerdown({ pointerType }); + // document scrolls + doc.scroll(); + }); + // responder end + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // no responder should be set + expect(getResponderNode()).toBe(null); + }); + + test('is not called if sibling scrolls', () => { + const pointerType = 'touch'; + const siblingRef = createRef(); + + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const sibling = createEventTarget(siblingRef.current); + // gesture start & scroll + act(() => { + target.pointerdown({ pointerType }); + // sibling scrolls + sibling.scroll(); + }); + // responder doesn't terminate + expect(targetCallbacks.onResponderTerminate).not.toBeCalled(); + // responder should still be set + expect(getResponderNode()).toBe(targetRef.current); + }); + + test('is called if responder blurs', () => { + const pointerType = 'touch'; + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn(), + onResponderTerminationRequest: jest.fn(() => false) + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const doc = createEventTarget(document); + // gesture start & blur + act(() => { + target.pointerdown({ pointerType }); + doc.focus({ relatedTarget: target.node }); + }); + // responder terminates + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // responder should still be set + expect(getResponderNode()).toBe(null); + }); + + test('is called if window blurs', () => { + const pointerType = 'touch'; + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn(), + onResponderTerminationRequest: jest.fn(() => false) + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const win = createEventTarget(window); + // gesture start & blur + act(() => { + target.pointerdown({ pointerType }); + win.blur({ relatedTarget: target.node }); + }); + // responder terminates + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // responder should not be set + expect(getResponderNode()).toBe(null); + }); + + test('is not called if sibling blurs', () => { + const pointerType = 'touch'; + const siblingRef = createRef(); + + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn() + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const sibling = createEventTarget(siblingRef.current); + // gesture start & blur + act(() => { + target.pointerdown({ pointerType }); + // sibling blurs + sibling.blur({ relatedTarget: target.node }); + }); + // responder doesn't terminate + expect(targetCallbacks.onResponderTerminate).not.toBeCalled(); + // responder should still be set + expect(getResponderNode()).toBe(targetRef.current); + }); + + test('is called if contextmenu opens', () => { + // only test 'touch' because nothing can become the responder + // when using mouse right-click to open a context menu + const pointerType = 'touch'; + const targetCallbacks = { + onStartShouldSetResponder: jest.fn(() => true), + onResponderTerminate: jest.fn(), + onResponderTerminationRequest: jest.fn(() => false) + }; + + const Component = () => { + useResponderEvents(targetRef, targetCallbacks); + return
; + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // contextmenu sequence includes pointerdown "start" + act(() => { + target.contextmenu({ pointerType }); + }); + // responder terminates + expect(targetCallbacks.onResponderTerminate).toBeCalledTimes(1); + // responder should not be set + expect(getResponderNode()).toBe(null); + }); + }); + + /** + * Negotiation of responder from common ancestor + */ + + describe('Negotiation', () => { + let grandParentRef; + let parentRef; + let siblingRef; + let targetRef; + + beforeEach(() => { + grandParentRef = createRef(); + parentRef = createRef(); + siblingRef = createRef(); + targetRef = createRef(); + }); + + /** + * When there is an active responder, negotiation captures to and bubbles from + * the ancestor registered with the system. The responder is transferred and + * the relevant termination events are called. + */ + test('negotiates from first registered ancestor of responder and transfers', () => { + const pointerType = 'touch'; + let eventLog = []; + const grandParentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('grandParent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('grandParent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('grandParent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('grandParent: onMoveShouldSetResponder'); + return true; + }, + onResponderGrant() { + eventLog.push('grandParent: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('grandParent: onResponderStart'); + }, + onResponderEnd() { + eventLog.push('grandParent: onResponderEnd'); + }, + onResponderRelease() { + eventLog.push('grandParent: onResponderRelease'); + }, + onResponderTerminate() { + eventLog.push('grandParent: onResponderTerminate'); + }, + onResponderTerminationRequest() { + eventLog.push('grandParent: onResponderTerminationRequest'); + return true; + } + }; + const parentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('parent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('parent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('parent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('parent: onMoveShouldSetResponder'); + return true; + }, + onResponderGrant() { + eventLog.push('parent: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('parent: onResponderStart'); + }, + onResponderEnd() { + eventLog.push('parent: onResponderEnd'); + }, + onResponderRelease() { + eventLog.push('parent: onResponderRelease'); + }, + onResponderTerminate() { + eventLog.push('parent: onResponderTerminate'); + }, + onResponderTerminationRequest() { + eventLog.push('parent: onResponderTerminationRequest'); + return true; + } + }; + const targetCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('target: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('target: onStartShouldSetResponder'); + return true; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('target: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('target: onMoveShouldSetResponder'); + return false; + }, + onResponderGrant() { + eventLog.push('target: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('target: onResponderStart'); + }, + onResponderEnd() { + eventLog.push('target: onResponderEnd'); + }, + onResponderRelease() { + eventLog.push('target: onResponderRelease'); + }, + onResponderTerminate() { + eventLog.push('target: onResponderTerminate'); + }, + onResponderTerminationRequest() { + eventLog.push('target: onResponderTerminationRequest'); + return true; + } + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + + // gesture start + act(() => { + target.pointerdown({ pointerType, pointerId: 1 }); + }); + // target becomes responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponder', + 'target: onResponderGrant', + 'target: onResponderStart' + ]); + eventLog = []; + // second gesture start + act(() => { + target.pointerdown({ pointerType, pointerId: 2 }); + }); + // target remains responder for start event + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponder', + 'grandParent: onStartShouldSetResponder', + 'target: onResponderStart' + ]); + eventLog = []; + // first move gesture + act(() => { + target.pointermove({ pointerType, pointerId: 1 }); + }); + // parent becomes responder, target terminates + expect(getResponderNode()).toBe(parentRef.current); + expect(eventLog).toEqual([ + 'grandParent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponder', + 'target: onResponderTerminationRequest', + 'target: onResponderTerminate', + 'parent: onResponderGrant' + ]); + eventLog = []; + // second move gesture + act(() => { + target.pointermove({ pointerType, pointerId: 1 }); + }); + // parent becomes responder, parent terminates + expect(getResponderNode()).toBe(grandParentRef.current); + expect(eventLog).toEqual([ + 'grandParent: onMoveShouldSetResponderCapture', + 'grandParent: onMoveShouldSetResponder', + 'parent: onResponderTerminationRequest', + 'parent: onResponderTerminate', + 'grandParent: onResponderGrant' + ]); + eventLog = []; + // end gestures + act(() => { + target.pointerup({ pointerType, pointerId: 2 }); + target.pointerup({ pointerType, pointerId: 1 }); + }); + expect(getResponderNode()).toBe(null); + expect(eventLog).toEqual([ + 'grandParent: onResponderEnd', + 'grandParent: onResponderEnd', + 'grandParent: onResponderRelease' + ]); + }); + + /** + * If nothing is responder, then the negotiation should propagate directly to + * the deepest target in the second touch. Once there are no more pointers + * that started within the responder, it should be released (even if there are + * active pointers elsewhere on the screen) + */ + test('negotiates with deepest target on second touch if nothing is responder', () => { + const pointerType = 'touch'; + let eventLog = []; + const grandParentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('grandParent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('grandParent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('grandParent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('grandParent: onMoveShouldSetResponder'); + return false; + } + }; + const parentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('parent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('parent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('parent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('parent: onMoveShouldSetResponder'); + return false; + } + }; + const targetCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('target: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('target: onStartShouldSetResponder'); + return true; + }, + onResponderGrant() { + eventLog.push('target: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('target: onResponderStart'); + }, + onResponderMove() { + eventLog.push('target: onResponderMove'); + }, + onResponderEnd() { + eventLog.push('target: onResponderEnd'); + }, + onResponderRelease() { + eventLog.push('target: onResponderRelease'); + } + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const parent = createEventTarget(parentRef.current); + const target = createEventTarget(targetRef.current); + + // gesture start on parent + act(() => { + parent.pointerdown({ pointerType, pointerId: 1 }); + }); + // initially nothing wants to become the responder + expect(getResponderNode()).toBe(null); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponder', + 'grandParent: onStartShouldSetResponder' + ]); + eventLog = []; + // second gesture start on target + act(() => { + target.pointerdown({ pointerType, pointerId: 2 }); + }); + // target should become responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponder', + 'target: onResponderGrant', + 'target: onResponderStart' + ]); + eventLog = []; + // remove first touch, keep second touch that + // started within the current responder (target). + act(() => { + parent.pointerup({ pointerType, pointerId: 1 }); + }); + // responder doesn't change, "end" event called on responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual(['target: onResponderEnd']); + eventLog = []; + // add touch back on parent + act(() => { + parent.pointerdown({ pointerType, pointerId: 1 }); + }); + // responder doesn't change, "start" event called on responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponder', + 'grandParent: onStartShouldSetResponder', + 'target: onResponderStart' + ]); + eventLog = []; + // move touch on parent + act(() => { + parent.pointermove({ pointerType, pointerId: 1 }); + }); + // responder doesn't change, "move" event dispatched on responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponder', + 'grandParent: onMoveShouldSetResponder', + 'target: onResponderMove' + ]); + eventLog = []; + // remove touch that started within current responder + act(() => { + target.pointerup({ pointerType, pointerId: 2 }); + }); + // responder is released + expect(getResponderNode()).toBe(null); + expect(eventLog).toEqual(['target: onResponderEnd', 'target: onResponderRelease']); + }); + + /** + * If a node is responder, then the negotiation with a sibling should + * capture to and bubble from the first common ancestor. + */ + test('negotiate from first common ancestor when there are siblings', () => { + const pointerType = 'touch'; + let eventLog = []; + const grandParentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('grandParent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('grandParent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('grandParent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('grandParent: onMoveShouldSetResponder'); + return false; + } + }; + const parentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('parent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('parent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('parent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('parent: onMoveShouldSetResponder'); + return false; + }, + onResponderGrant() { + eventLog.push('parent: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('parent: onResponderStart'); + } + }; + const targetCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('target: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('target: onStartShouldSetResponder'); + return true; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('target: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('target: onMoveShouldSetResponder'); + return false; + }, + onResponderGrant() { + eventLog.push('target: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('target: onResponderStart'); + }, + onResponderMove() { + eventLog.push('target: onResponderMove'); + } + }; + const siblingCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('sibling: onStartShouldSetResponderCapture'); + return true; + }, + onResponderGrant() { + eventLog.push('sibling: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('sibling: onResponderStart'); + } + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + useResponderEvents(siblingRef, siblingCallbacks); + return ( +
+
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const sibling = createEventTarget(siblingRef.current); + // gesture start on target + act(() => { + target.pointerdown({ pointerType, pointerId: 1 }); + }); + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponder', + 'target: onResponderGrant', + 'target: onResponderStart' + ]); + eventLog = []; + // second gesture start on sibling + act(() => { + sibling.pointerdown({ pointerType, pointerId: 2 }); + }); + // target remains responder + expect(getResponderNode()).toBe(targetRef.current); + // negotiates from first common ancestor of current responder and sibling (i.e., parent) + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponder', + 'grandParent: onStartShouldSetResponder', + 'target: onResponderStart' + ]); + eventLog = []; + // gesture move on target + act(() => { + target.pointermove({ pointerType, pointerId: 1 }); + }); + // target remains responder + expect(getResponderNode()).toBe(targetRef.current); + // negotiates from first common ancestor + expect(eventLog).toEqual([ + 'grandParent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponder', + 'grandParent: onMoveShouldSetResponder', + 'target: onResponderMove' + ]); + eventLog = []; + // gesture move on sibling + act(() => { + sibling.pointermove({ pointerType, pointerId: 2 }); + }); + // target remains responder + expect(getResponderNode()).toBe(targetRef.current); + // negotiates from first common ancestor + expect(eventLog).toEqual([ + 'grandParent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponder', + 'grandParent: onMoveShouldSetResponder', + 'target: onResponderMove' + ]); + }); + + /** + * If a node is responder and it rejects a termination request, it + * should continue to receive responder events. + */ + test('negotiation rejects and current responder receives events', () => { + const pointerType = 'touch'; + let eventLog = []; + const grandParentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('grandParent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('grandParent: onStartShouldSetResponder'); + return false; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('grandParent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('grandParent: onMoveShouldSetResponder'); + return false; + } + }; + const parentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('parent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('parent: onStartShouldSetResponder'); + return true; + }, + onMoveShouldSetResponderCapture() { + eventLog.push('parent: onMoveShouldSetResponderCapture'); + return false; + }, + onMoveShouldSetResponder() { + eventLog.push('parent: onMoveShouldSetResponder'); + return true; + }, + onResponderReject() { + eventLog.push('parent: onResponderReject'); + } + }; + const targetCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('target: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('target: onStartShouldSetResponder'); + return true; + }, + onResponderGrant() { + eventLog.push('target: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('target: onResponderStart'); + }, + onResponderMove() { + eventLog.push('target: onResponderMove'); + }, + onResponderTerminationRequest() { + eventLog.push('target: onResponderTerminationRequest'); + return false; + } + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + // first touch + act(() => { + target.pointerdown({ pointerType }); + }); + // target becomes responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponder', + 'target: onResponderGrant', + 'target: onResponderStart' + ]); + eventLog = []; + // move first touch + act(() => { + target.pointermove({ pointerType }); + }); + // target remains responder, parent was rejected + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponderCapture', + 'parent: onMoveShouldSetResponder', + 'target: onResponderTerminationRequest', + 'parent: onResponderReject', + 'target: onResponderMove' + ]); + eventLog = []; + // add second touch + act(() => { + target.pointerdown({ pointerType, pointerId: 2 }); + }); + // target remains responder, parent was rejected + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponder', + 'target: onResponderTerminationRequest', + 'parent: onResponderReject', + 'target: onResponderStart' + ]); + }); + + test('negotiate scroll', () => { + const pointerType = 'touch'; + let eventLog = []; + const grandParentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('grandParent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('grandParent: onStartShouldSetResponder'); + return false; + }, + onScrollShouldSetResponderCapture() { + eventLog.push('grandParent: onScrollShouldSetResponderCapture'); + return false; + }, + onScrollShouldSetResponder() { + eventLog.push('grandParent: onScrollShouldSetResponder'); + return false; + } + }; + const parentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('parent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('parent: onStartShouldSetResponder'); + return false; + }, + onScrollShouldSetResponderCapture() { + eventLog.push('parent: onScrollShouldSetResponderCapture'); + return false; + }, + onScrollShouldSetResponder() { + eventLog.push('parent: onScrollShouldSetResponder'); + return true; + }, + onResponderGrant() { + eventLog.push('parent: onResponderGrant'); + }, + onResponderReject() { + eventLog.push('parent: onResponderReject'); + } + }; + const targetCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('target: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('target: onStartShouldSetResponder'); + return true; + }, + onResponderGrant() { + eventLog.push('target: onResponderGrant'); + }, + onResponderStart() { + eventLog.push('target: onResponderStart'); + }, + onResponderMove() { + eventLog.push('target: onResponderMove'); + }, + onResponderTerminate() { + eventLog.push('target: onResponderTerminate'); + }, + onResponderTerminationRequest() { + eventLog.push('target: onResponderTerminationRequest'); + // responders can avoid termination only for scroll events + return false; + } + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + const parent = createEventTarget(parentRef.current); + // first touch + act(() => { + target.pointerdown({ pointerType }); + }); + // target becomes responder + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponderCapture', + 'target: onStartShouldSetResponder', + 'target: onResponderGrant', + 'target: onResponderStart' + ]); + eventLog = []; + // then scroll + act(() => { + parent.scroll(); + }); + // target remains responder, parent rejected + expect(getResponderNode()).toBe(targetRef.current); + expect(eventLog).toEqual([ + 'grandParent: onScrollShouldSetResponderCapture', + 'parent: onScrollShouldSetResponderCapture', + 'parent: onScrollShouldSetResponder', + 'target: onResponderTerminationRequest', + 'parent: onResponderReject' + ]); + eventLog = []; + // now let the parent scroll take over + targetCallbacks.onResponderTerminationRequest = function() { + eventLog.push('target: onResponderTerminationRequest'); + return true; + }; + // scroll + act(() => { + parent.scroll(); + }); + expect(getResponderNode()).toBe(parentRef.current); + expect(eventLog).toEqual([ + 'grandParent: onScrollShouldSetResponderCapture', + 'parent: onScrollShouldSetResponderCapture', + 'parent: onScrollShouldSetResponder', + 'target: onResponderTerminationRequest', + 'target: onResponderTerminate', + 'parent: onResponderGrant' + ]); + }); + + test('event stopPropagation ', () => { + const pointerType = 'touch'; + const eventLog = []; + const grandParentCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('grandParent: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('grandParent: onStartShouldSetResponder'); + return false; + } + }; + const parentCallbacks = { + onStartShouldSetResponderCapture(e) { + eventLog.push('parent: onStartShouldSetResponderCapture'); + e.stopPropagation(); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('parent: onStartShouldSetResponder'); + return false; + } + }; + const targetCallbacks = { + onStartShouldSetResponderCapture() { + eventLog.push('target: onStartShouldSetResponderCapture'); + return false; + }, + onStartShouldSetResponder() { + eventLog.push('target: onStartShouldSetResponder'); + return true; + } + }; + + const Component = () => { + useResponderEvents(grandParentRef, grandParentCallbacks); + useResponderEvents(parentRef, parentCallbacks); + useResponderEvents(targetRef, targetCallbacks); + return ( +
+
+
+
+
+ ); + }; + + // render + act(() => { + render(); + }); + const target = createEventTarget(targetRef.current); + act(() => { + target.pointerdown({ pointerType }); + }); + expect(eventLog).toEqual([ + 'grandParent: onStartShouldSetResponderCapture', + 'parent: onStartShouldSetResponderCapture' + ]); + expect(getResponderNode()).toBe(null); + }); + }); +}); diff --git a/packages/react-native-web/src/hooks/useResponderEvents/createResponderEvent.js b/packages/react-native-web/src/hooks/useResponderEvents/createResponderEvent.js new file mode 100644 index 000000000..8958c6dd2 --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/createResponderEvent.js @@ -0,0 +1,164 @@ +/** + * Copyright (c) Nicolas Gallagher + * + * 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 { TouchEvent } from './ResponderEventTypes'; + +import getBoundingClientRect from '../../modules/getBoundingClientRect'; +import ResponderTouchHistoryStore from './ResponderTouchHistoryStore'; + +export type ResponderEvent = {| + currentTarget: any, + defaultPrevented: ?boolean, + dispatchConfig: {}, + eventPhase: ?number, + isDefaultPrevented: () => boolean, + isPropagationStopped: () => boolean, + isTrusted: ?boolean, + preventDefault: () => void, + stopPropagation: () => void, + nativeEvent: TouchEvent, + persist: () => void, + target: ?any, + timeStamp: number, + touchHistory: $ReadOnly<{| + indexOfSingleActiveTouch: number, + mostRecentTimeStamp: number, + numberActiveTouches: number, + touchBank: Array<{| + currentPageX: number, + currentPageY: number, + currentTimeStamp: number, + previousPageX: number, + previousPageY: number, + previousTimeStamp: number, + startPageX: number, + startPageY: number, + startTimeStamp: number, + touchActive: boolean + |}> + |}> +|}; + +const emptyFunction = () => {}; +const emptyObject = {}; +const emptyArray = []; + +/** + * Converts a native DOM event to a ResponderEvent. + * Mouse events are transformed into fake touch events. + */ +export default function createResponderEvent(domEvent: any): ResponderEvent { + let rect; + let propagationWasStopped = false; + let changedTouches; + let touches; + + const domEventChangedTouches = domEvent.changedTouches; + + const force = (domEventChangedTouches && domEventChangedTouches[0].force) || 0; + const identifier = (domEventChangedTouches && domEventChangedTouches[0].identifier) || 0; + const clientX = (domEventChangedTouches && domEventChangedTouches[0].clientX) || domEvent.clientX; + const clientY = (domEventChangedTouches && domEventChangedTouches[0].clientY) || domEvent.clientY; + const pageX = (domEventChangedTouches && domEventChangedTouches[0].pageX) || domEvent.pageX; + const pageY = (domEventChangedTouches && domEventChangedTouches[0].pageY) || domEvent.pageY; + const preventDefault = + typeof domEvent.preventDefault === 'function' + ? domEvent.preventDefault.bind(domEvent) + : emptyFunction; + const timestamp = domEvent.timeStamp; + + function normalizeTouches(touches) { + return Array.prototype.slice.call(touches).map(touch => { + touch.timestamp = timestamp; + return touch; + }); + } + + if (domEventChangedTouches != null) { + changedTouches = normalizeTouches(domEventChangedTouches); + touches = normalizeTouches(domEvent.touches); + } else { + const emulatedTouches = [ + { + force, + identifier, + get locationX() { + return locationX(); + }, + get locationY() { + return locationY(); + }, + pageX, + pageY, + target: domEvent.target, + timestamp + } + ]; + changedTouches = emulatedTouches; + touches = + domEvent.type === 'mouseup' || domEvent.type === 'dragstart' ? emptyArray : emulatedTouches; + } + + const responderEvent = { + // `currentTarget` is set before dispatch + currentTarget: null, + defaultPrevented: domEvent.defaultPrevented, + dispatchConfig: emptyObject, + eventPhase: domEvent.eventPhase, + isDefaultPrevented() { + return domEvent.defaultPrevented; + }, + isPropagationStopped() { + return propagationWasStopped; + }, + isTrusted: domEvent.isTrusted, + nativeEvent: { + changedTouches, + force, + identifier, + get locationX() { + return locationX(); + }, + get locationY() { + return locationY(); + }, + pageX, + pageY, + target: domEvent.target, + timestamp, + touches + }, + persist: emptyFunction, + preventDefault, + stopPropagation() { + propagationWasStopped = true; + }, + target: domEvent.target, + timeStamp: timestamp, + touchHistory: ResponderTouchHistoryStore.touchHistory + }; + + // Using getters and functions serves two purposes: + // 1) The value of `currentTarget` is not initially available. + // 2) Measuring the clientRect may cause layout jank and should only be done on-demand. + function locationX() { + rect = rect || getBoundingClientRect(responderEvent.currentTarget); + if (rect) { + return clientX - rect.left; + } + } + function locationY() { + rect = rect || getBoundingClientRect(responderEvent.currentTarget); + if (rect) { + return clientY - rect.top; + } + } + + return responderEvent; +} diff --git a/packages/react-native-web/src/hooks/useResponderEvents/index.js b/packages/react-native-web/src/hooks/useResponderEvents/index.js new file mode 100644 index 000000000..edee5bd59 --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/index.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) Nicolas Gallagher + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +/** + * Hook for integrating the Responder System into React + * + * function SomeComponent({ onStartShouldSetResponder }) { + * const ref = useRef(null); + * useResponderEvents(ref, { onStartShouldSetResponder }); + * return
+ * } + */ + +import type { ResponderCallbacks } from './ResponderSystem'; + +import * as React from 'react'; +import * as ResponderSystem from './ResponderSystem'; + +const emptyObject = {}; +let idCounter = 0; + +function useStable(getInitialValue: () => T): T { + const ref = React.useRef(null); + if (ref.current == null) { + ref.current = getInitialValue(); + } + return ref.current; +} + +export default function useResponderEvents( + hostRef: any, + callbacks: ResponderCallbacks = emptyObject +) { + const id = useStable(() => idCounter++); + const isAttachedRef = React.useRef(false); + + // These are separate effects so they doesn't run when `callbacks` changes. + // On initial mount, attach global listeners if needed. + React.useEffect(() => { + ResponderSystem.attachListeners(); + }, []); + // On unmount, remove node potentially attached to the Responder System. + React.useEffect(() => { + return () => { + ResponderSystem.removeNode(id); + }; + }, [id]); + + // Register and unregister with the Responder System as necessary + React.useEffect(() => { + const { + onMoveShouldSetResponder, + onMoveShouldSetResponderCapture, + onScrollShouldSetResponder, + onScrollShouldSetResponderCapture, + onSelectionChangeShouldSetResponder, + onSelectionChangeShouldSetResponderCapture, + onStartShouldSetResponder, + onStartShouldSetResponderCapture + } = callbacks; + + const requiresResponderSystem = + onMoveShouldSetResponder != null || + onMoveShouldSetResponderCapture != null || + onScrollShouldSetResponder != null || + onScrollShouldSetResponderCapture != null || + onSelectionChangeShouldSetResponder != null || + onSelectionChangeShouldSetResponderCapture != null || + onStartShouldSetResponder != null || + onStartShouldSetResponderCapture != null; + + const node = hostRef.current; + + if (requiresResponderSystem) { + ResponderSystem.addNode(id, node, callbacks); + isAttachedRef.current = true; + } else if (isAttachedRef.current) { + ResponderSystem.removeNode(id); + isAttachedRef.current = false; + } + }, [callbacks, hostRef, id]); +} diff --git a/packages/react-native-web/src/hooks/useResponderEvents/utils.js b/packages/react-native-web/src/hooks/useResponderEvents/utils.js new file mode 100644 index 000000000..5cfe3abba --- /dev/null +++ b/packages/react-native-web/src/hooks/useResponderEvents/utils.js @@ -0,0 +1,172 @@ +/** + * Copyright (c) Nicolas Gallagher + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +const keyName = '__reactResponderId'; + +function getEventPath(domEvent: any): Array { + // The 'selectionchange' event always has the 'document' as the target. + // Use the anchor node as the initial target to reconstruct a path. + // (We actually only need the first "responder" node in practice.) + if (domEvent.type === 'selectionchange') { + const target = window.getSelection().anchorNode; + return composedPathFallback(target); + } else { + const path = + domEvent.composedPath != null + ? domEvent.composedPath() + : composedPathFallback(domEvent.target); + return path; + } +} + +function composedPathFallback(target: any): Array { + const path = []; + while (target != null && target !== document.body) { + path.push(target); + target = target.parentNode; + } + return path; +} + +/** + * Retrieve the responderId from a host node + */ +function getResponderId(node: any): ?number { + if (node != null) { + return node[keyName]; + } + return null; +} + +/** + * Store the responderId on a host node + */ +export function setResponderId(node: any, id: number) { + if (node != null) { + node[keyName] = id; + } +} + +/** + * Filter the event path to contain only the nodes attached to the responder system + */ +export function getResponderPaths( + domEvent: any +): {| idPath: Array, nodePath: Array |} { + const idPath = []; + const nodePath = []; + const eventPath = getEventPath(domEvent); + for (let i = 0; i < eventPath.length; i++) { + const node = eventPath[i]; + const id = getResponderId(node); + if (id != null) { + idPath.push(id); + nodePath.push(node); + } + } + return { idPath, nodePath }; +} + +/** + * Walk the paths and find the first common ancestor + */ +export function getLowestCommonAncestor(pathA: Array, pathB: Array) { + let pathALength = pathA.length; + let pathBLength = pathB.length; + if ( + // If either path is empty + pathALength === 0 || + pathBLength === 0 || + // If the last elements aren't the same there can't be a common ancestor + // that is connected to the responder system + pathA[pathALength - 1] !== pathB[pathBLength - 1] + ) { + return null; + } + + let itemA = pathA[0]; + let indexA = 0; + let itemB = pathB[0]; + let indexB = 0; + + // If A is deeper, skip indices that can't match. + if (pathALength - pathBLength > 0) { + indexA = pathALength - pathBLength; + itemA = pathA[indexA]; + pathALength = pathBLength; + } + + // If B is deeper, skip indices that can't match + if (pathBLength - pathALength > 0) { + indexB = pathBLength - pathALength; + itemB = pathB[indexB]; + pathBLength = pathALength; + } + + // Walk in lockstep until a match is found + let depth = pathALength; + while (depth--) { + if (itemA === itemB) { + return itemA; + } + itemA = pathA[indexA++]; + itemB = pathB[indexB++]; + } + return null; +} + +/** + * Determine whether any of the active touches are within the current responder. + * This cannot rely on W3C `targetTouches`, as neither IE11 nor Safari implement it. + */ +export function hasTargetTouches(target: any, touches: any): boolean { + if (!touches || touches.length === 0) { + return false; + } + for (let i = 0; i < touches.length; i++) { + const node = touches[i].target; + if (node != null) { + if (target.contains(node)) { + return true; + } + } + } + return false; +} + +/** + * The browser fires a lot of 'selectionchange' events. This can be used to ignore + * meaningless selections. + */ +export function hasValidSelection(domEvent: any) { + if (domEvent.type === 'selectionchange') { + const selection = window.getSelection().toString(); + const anchorNode = window.getSelection().anchorNode; + const isTextNode = anchorNode && anchorNode.nodeType === window.Node.TEXT_NODE; + return selection.length >= 1 && selection !== '\n' && isTextNode; + } + return domEvent.type === 'select'; +} + +/** + * Events are only valid if the primary button was used without modifier keys. + */ +export function isPrimaryPointerDown(domEvent: any): boolean { + const { altKey, button, buttons, ctrlKey, metaKey, shiftKey, type } = domEvent; + const isTouch = type === 'touchstart' || type === 'touchmove'; + const isPrimaryMouseDown = type === 'mousedown' && (button === 0 || buttons === 1); + const isPrimaryMouseMove = type === 'mousemove' && buttons === 1; + const noModifiers = + altKey === false && ctrlKey === false && metaKey === false && shiftKey === false; + + if (isTouch || (isPrimaryMouseDown && noModifiers) || (isPrimaryMouseMove && noModifiers)) { + return true; + } + return false; +} diff --git a/packages/react-native-web/src/modules/ResponderEventPlugin/index.js b/packages/react-native-web/src/modules/ResponderEventPlugin/index.js deleted file mode 100644 index 75a359be5..000000000 --- a/packages/react-native-web/src/modules/ResponderEventPlugin/index.js +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Copyright (c) Nicolas Gallagher. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @noflow - */ - -// based on https://github.com/facebook/react/pull/4303/files - -import normalizeNativeEvent from '../normalizeNativeEvent'; -import ReactDOMUnstableNativeDependencies from 'react-dom/unstable-native-dependencies'; - -const { ResponderEventPlugin, ResponderTouchHistoryStore } = ReactDOMUnstableNativeDependencies; - -// On older versions of React (< 16.4) we have to inject the dependencies in -// order for the plugin to work properly in the browser. This version still -// uses `top*` strings to identify the internal event names. -// https://github.com/facebook/react/pull/12629 -if (!ResponderEventPlugin.eventTypes.responderMove.dependencies) { - const topMouseDown = 'topMouseDown'; - const topMouseMove = 'topMouseMove'; - const topMouseUp = 'topMouseUp'; - const topScroll = 'topScroll'; - const topSelectionChange = 'topSelectionChange'; - const topTouchCancel = 'topTouchCancel'; - const topTouchEnd = 'topTouchEnd'; - const topTouchMove = 'topTouchMove'; - const topTouchStart = 'topTouchStart'; - - const endDependencies = [topTouchCancel, topTouchEnd, topMouseUp]; - const moveDependencies = [topTouchMove, topMouseMove]; - const startDependencies = [topTouchStart, topMouseDown]; - - /** - * Setup ResponderEventPlugin dependencies - */ - ResponderEventPlugin.eventTypes.responderMove.dependencies = moveDependencies; - ResponderEventPlugin.eventTypes.responderEnd.dependencies = endDependencies; - ResponderEventPlugin.eventTypes.responderStart.dependencies = startDependencies; - ResponderEventPlugin.eventTypes.responderRelease.dependencies = endDependencies; - ResponderEventPlugin.eventTypes.responderTerminationRequest.dependencies = []; - ResponderEventPlugin.eventTypes.responderGrant.dependencies = []; - ResponderEventPlugin.eventTypes.responderReject.dependencies = []; - ResponderEventPlugin.eventTypes.responderTerminate.dependencies = []; - ResponderEventPlugin.eventTypes.moveShouldSetResponder.dependencies = moveDependencies; - ResponderEventPlugin.eventTypes.selectionChangeShouldSetResponder.dependencies = [ - topSelectionChange - ]; - ResponderEventPlugin.eventTypes.scrollShouldSetResponder.dependencies = [topScroll]; - ResponderEventPlugin.eventTypes.startShouldSetResponder.dependencies = startDependencies; -} - -let lastActiveTouchTimestamp = null; -// The length of time after a touch that we ignore the browser's emulated mouse events -// https://developer.mozilla.org/en-US/docs/Web/API/Touch_events/Using_Touch_Events -const EMULATED_MOUSE_THERSHOLD_MS = 1000; - -const originalExtractEvents = ResponderEventPlugin.extractEvents; -ResponderEventPlugin.extractEvents = (topLevelType, targetInst, nativeEvent, nativeEventTarget) => { - const hasActiveTouches = ResponderTouchHistoryStore.touchHistory.numberActiveTouches > 0; - const eventType = nativeEvent.type; - - let shouldSkipMouseAfterTouch = false; - if (eventType.indexOf('touch') > -1) { - lastActiveTouchTimestamp = Date.now(); - } else if (lastActiveTouchTimestamp && eventType.indexOf('mouse') > -1) { - const now = Date.now(); - shouldSkipMouseAfterTouch = now - lastActiveTouchTimestamp < EMULATED_MOUSE_THERSHOLD_MS; - } - - if ( - // Filter out mousemove and mouseup events when a touch hasn't started yet - ((eventType === 'mousemove' || eventType === 'mouseup') && !hasActiveTouches) || - // Filter out events from wheel/middle and right click. - (nativeEvent.button === 1 || nativeEvent.button === 2) || - // Filter out mouse events that browsers dispatch immediately after touch events end - // Prevents the REP from calling handlers twice for touch interactions. - // See #802 and #932. - shouldSkipMouseAfterTouch - ) { - return; - } - - const normalizedEvent = normalizeNativeEvent(nativeEvent); - - return originalExtractEvents.call( - ResponderEventPlugin, - topLevelType, - targetInst, - normalizedEvent, - nativeEventTarget - ); -}; - -export default ResponderEventPlugin; diff --git a/packages/react-native-web/src/modules/getBoundingClientRect/index.js b/packages/react-native-web/src/modules/getBoundingClientRect/index.js index 00eb0395e..8e3cb004c 100644 --- a/packages/react-native-web/src/modules/getBoundingClientRect/index.js +++ b/packages/react-native-web/src/modules/getBoundingClientRect/index.js @@ -7,10 +7,8 @@ * @flow strict */ -/* global HTMLElement */ - -const getBoundingClientRect = (node: HTMLElement) => { - if (node) { +const getBoundingClientRect = (node: ?HTMLElement) => { + if (node != null) { const isElement = node.nodeType === 1; /* Node.ELEMENT_NODE */ if (isElement && typeof node.getBoundingClientRect === 'function') { return node.getBoundingClientRect(); diff --git a/packages/react-native-web/src/modules/normalizeNativeEvent/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/modules/normalizeNativeEvent/__tests__/__snapshots__/index-test.js.snap deleted file mode 100644 index c7fa1af56..000000000 --- a/packages/react-native-web/src/modules/normalizeNativeEvent/__tests__/__snapshots__/index-test.js.snap +++ /dev/null @@ -1,144 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`modules/normalizeNativeEvent mouse events simulated event 1`] = ` -Object { - "_normalized": true, - "bubbles": undefined, - "cancelable": undefined, - "changedTouches": Array [ - Object { - "_normalized": true, - "clientX": undefined, - "clientY": undefined, - "force": undefined, - "identifier": 0, - "locationX": undefined, - "locationY": undefined, - "pageX": undefined, - "pageY": undefined, - "screenX": undefined, - "screenY": undefined, - "target": undefined, - "timestamp": 1496876171255, - }, - ], - "defaultPrevented": undefined, - "identifier": 0, - "locationX": undefined, - "locationY": undefined, - "pageX": undefined, - "pageY": undefined, - "preventDefault": [Function], - "stopImmediatePropagation": [Function], - "stopPropagation": [Function], - "target": undefined, - "timestamp": 1496876171255, - "touches": Array [], - "type": "mouseup", - "which": undefined, -} -`; - -exports[`modules/normalizeNativeEvent mouse events synthetic event 1`] = ` -Object { - "_normalized": true, - "bubbles": undefined, - "cancelable": undefined, - "changedTouches": Array [ - Object { - "_normalized": true, - "clientX": 100, - "clientY": 100, - "force": false, - "identifier": 0, - "locationX": undefined, - "locationY": undefined, - "pageX": 300, - "pageY": 300, - "screenX": 400, - "screenY": 400, - "target": undefined, - "timestamp": 1496876171255, - }, - ], - "defaultPrevented": undefined, - "identifier": 0, - "locationX": undefined, - "locationY": undefined, - "pageX": 300, - "pageY": 300, - "preventDefault": [Function], - "stopImmediatePropagation": [Function], - "stopPropagation": [Function], - "target": undefined, - "timestamp": 1496876171255, - "touches": Array [], - "type": "mouseup", - "which": undefined, -} -`; - -exports[`modules/normalizeNativeEvent touch events simulated event 1`] = ` -Object { - "_normalized": true, - "bubbles": undefined, - "cancelable": undefined, - "changedTouches": Array [], - "defaultPrevented": undefined, - "identifier": undefined, - "locationX": undefined, - "locationY": undefined, - "pageX": undefined, - "pageY": undefined, - "preventDefault": [Function], - "stopImmediatePropagation": [Function], - "stopPropagation": [Function], - "target": undefined, - "timestamp": 1496876171255, - "touches": Array [], - "type": "touchstart", - "which": undefined, -} -`; - -exports[`modules/normalizeNativeEvent touch events synthetic event 1`] = ` -Object { - "_normalized": true, - "bubbles": undefined, - "cancelable": undefined, - "changedTouches": Array [ - Object { - "_normalized": true, - "clientX": 100, - "clientY": 100, - "force": false, - "identifier": undefined, - "locationX": undefined, - "locationY": undefined, - "pageX": 300, - "pageY": 300, - "radiusX": 10, - "radiusY": 10, - "rotationAngle": 45, - "screenX": 400, - "screenY": 400, - "target": undefined, - "timestamp": 1496876171255, - }, - ], - "defaultPrevented": undefined, - "identifier": undefined, - "locationX": undefined, - "locationY": undefined, - "pageX": 300, - "pageY": 300, - "preventDefault": [Function], - "stopImmediatePropagation": [Function], - "stopPropagation": [Function], - "target": undefined, - "timestamp": 1496876171255, - "touches": Array [], - "type": "touchstart", - "which": undefined, -} -`; diff --git a/packages/react-native-web/src/modules/normalizeNativeEvent/__tests__/index-test.js b/packages/react-native-web/src/modules/normalizeNativeEvent/__tests__/index-test.js deleted file mode 100644 index 4f7aea590..000000000 --- a/packages/react-native-web/src/modules/normalizeNativeEvent/__tests__/index-test.js +++ /dev/null @@ -1,82 +0,0 @@ -/* eslint-env jasmine, jest */ - -import normalizeNativeEvent from '..'; - -const normalizeEvent = nativeEvent => { - const result = normalizeNativeEvent(nativeEvent); - result.timestamp = 1496876171255; - if (result.changedTouches && result.changedTouches[0]) { - result.changedTouches[0].timestamp = 1496876171255; - } - if (result.touches && result.touches[0]) { - result.touches[0].timestamp = 1496876171255; - } - return result; -}; - -describe('modules/normalizeNativeEvent', () => { - describe('mouse events', () => { - test('simulated event', () => { - const nativeEvent = { - type: 'mouseup' - }; - - const result = normalizeEvent(nativeEvent); - expect(result).toMatchSnapshot(); - }); - - test('synthetic event', () => { - const nativeEvent = { - type: 'mouseup', - clientX: 100, - clientY: 100, - force: false, - offsetX: 200, - offsetY: 200, - pageX: 300, - pageY: 300, - screenX: 400, - screenY: 400 - }; - - const result = normalizeEvent(nativeEvent); - expect(result).toMatchSnapshot(); - }); - }); - - describe('touch events', () => { - test('simulated event', () => { - const nativeEvent = { - type: 'touchstart' - }; - - const result = normalizeEvent(nativeEvent); - expect(result).toMatchSnapshot(); - }); - - test('synthetic event', () => { - const nativeEvent = { - type: 'touchstart', - changedTouches: [ - { - clientX: 100, - clientY: 100, - force: false, - pageX: 300, - pageY: 300, - radiusX: 10, - radiusY: 10, - rotationAngle: 45, - screenX: 400, - screenY: 400 - } - ], - pageX: 300, - pageY: 300 - }; - - const result = normalizeEvent(nativeEvent); - expect(result).toMatchSnapshot(); - }); - }); -}); diff --git a/packages/react-native-web/src/modules/normalizeNativeEvent/index.js b/packages/react-native-web/src/modules/normalizeNativeEvent/index.js deleted file mode 100644 index 1d0b61b11..000000000 --- a/packages/react-native-web/src/modules/normalizeNativeEvent/index.js +++ /dev/null @@ -1,190 +0,0 @@ -/** - * Copyright (c) Nicolas Gallagher. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import getBoundingClientRect from '../getBoundingClientRect'; - -const emptyArray = []; -const emptyFunction = () => {}; - -// Mobile Safari re-uses touch objects, so we copy the properties we want and normalize the identifier -const normalizeTouches = touches => { - if (!touches) { - return emptyArray; - } - - return Array.prototype.slice.call(touches).map(touch => { - const identifier = touch.identifier > 20 ? touch.identifier % 20 : touch.identifier; - let rect; - - return { - _normalized: true, - clientX: touch.clientX, - clientY: touch.clientY, - force: touch.force, - get locationX() { - rect = rect || getBoundingClientRect(touch.target); - if (rect) { - return touch.pageX - rect.left; - } - }, - get locationY() { - rect = rect || getBoundingClientRect(touch.target); - if (rect) { - return touch.pageY - rect.top; - } - }, - identifier: identifier, - pageX: touch.pageX, - pageY: touch.pageY, - radiusX: touch.radiusX, - radiusY: touch.radiusY, - rotationAngle: touch.rotationAngle, - screenX: touch.screenX, - screenY: touch.screenY, - target: touch.target, - // normalize the timestamp - // https://stackoverflow.com/questions/26177087/ios-8-mobile-safari-wrong-timestamp-on-touch-events - timestamp: Date.now() - }; - }); -}; - -function normalizeTouchEvent(nativeEvent) { - const changedTouches = normalizeTouches(nativeEvent.changedTouches); - const touches = normalizeTouches(nativeEvent.touches); - - const preventDefault = - typeof nativeEvent.preventDefault === 'function' - ? nativeEvent.preventDefault.bind(nativeEvent) - : emptyFunction; - const stopImmediatePropagation = - typeof nativeEvent.stopImmediatePropagation === 'function' - ? nativeEvent.stopImmediatePropagation.bind(nativeEvent) - : emptyFunction; - const stopPropagation = - typeof nativeEvent.stopPropagation === 'function' - ? nativeEvent.stopPropagation.bind(nativeEvent) - : emptyFunction; - const singleChangedTouch = changedTouches[0]; - - const event = { - _normalized: true, - bubbles: nativeEvent.bubbles, - cancelable: nativeEvent.cancelable, - changedTouches, - defaultPrevented: nativeEvent.defaultPrevented, - identifier: singleChangedTouch ? singleChangedTouch.identifier : undefined, - get locationX() { - return singleChangedTouch ? singleChangedTouch.locationX : undefined; - }, - get locationY() { - return singleChangedTouch ? singleChangedTouch.locationY : undefined; - }, - pageX: singleChangedTouch ? singleChangedTouch.pageX : nativeEvent.pageX, - pageY: singleChangedTouch ? singleChangedTouch.pageY : nativeEvent.pageY, - preventDefault, - stopImmediatePropagation, - stopPropagation, - target: nativeEvent.target, - // normalize the timestamp - // https://stackoverflow.com/questions/26177087/ios-8-mobile-safari-wrong-timestamp-on-touch-events - timestamp: Date.now(), - touches, - type: nativeEvent.type, - which: nativeEvent.which - }; - - return event; -} - -function normalizeMouseEvent(nativeEvent) { - let rect; - - const touches = [ - { - _normalized: true, - clientX: nativeEvent.clientX, - clientY: nativeEvent.clientY, - force: nativeEvent.force, - identifier: 0, - get locationX() { - rect = rect || getBoundingClientRect(nativeEvent.target); - if (rect) { - return nativeEvent.pageX - rect.left; - } - }, - get locationY() { - rect = rect || getBoundingClientRect(nativeEvent.target); - if (rect) { - return nativeEvent.pageY - rect.top; - } - }, - pageX: nativeEvent.pageX, - pageY: nativeEvent.pageY, - screenX: nativeEvent.screenX, - screenY: nativeEvent.screenY, - target: nativeEvent.target, - timestamp: Date.now() - } - ]; - - const preventDefault = - typeof nativeEvent.preventDefault === 'function' - ? nativeEvent.preventDefault.bind(nativeEvent) - : emptyFunction; - const stopImmediatePropagation = - typeof nativeEvent.stopImmediatePropagation === 'function' - ? nativeEvent.stopImmediatePropagation.bind(nativeEvent) - : emptyFunction; - const stopPropagation = - typeof nativeEvent.stopPropagation === 'function' - ? nativeEvent.stopPropagation.bind(nativeEvent) - : emptyFunction; - - return { - _normalized: true, - bubbles: nativeEvent.bubbles, - cancelable: nativeEvent.cancelable, - changedTouches: touches, - defaultPrevented: nativeEvent.defaultPrevented, - identifier: touches[0].identifier, - get locationX() { - return touches[0].locationX; - }, - get locationY() { - return touches[0].locationY; - }, - pageX: nativeEvent.pageX, - pageY: nativeEvent.pageY, - preventDefault, - stopImmediatePropagation, - stopPropagation, - target: nativeEvent.target, - timestamp: touches[0].timestamp, - touches: nativeEvent.type === 'mouseup' ? emptyArray : touches, - type: nativeEvent.type, - which: nativeEvent.which - }; -} - -// TODO: how to best handle keyboard events? -function normalizeNativeEvent(nativeEvent: Object) { - if (!nativeEvent || nativeEvent._normalized) { - return nativeEvent; - } - const eventType = nativeEvent.type || ''; - const mouse = eventType.indexOf('mouse') >= 0; - if (mouse) { - return normalizeMouseEvent(nativeEvent); - } else { - return normalizeTouchEvent(nativeEvent); - } -} - -export default normalizeNativeEvent;