diff --git a/packages/events/EventPluginUtils.js b/packages/events/EventPluginUtils.js index ac7cd97dd1514..2e9b7e9d16e5e 100644 --- a/packages/events/EventPluginUtils.js +++ b/packages/events/EventPluginUtils.js @@ -63,7 +63,7 @@ if (__DEV__) { * @param {function} listener Application-level callback * @param {*} inst Internal component instance */ -function executeDispatch(event, listener, inst) { +export function executeDispatch(event, listener, inst) { const type = event.type || 'unknown-event'; event.currentTarget = getNodeFromInstance(inst); invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event); diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js new file mode 100644 index 0000000000000..bba4e239881bd --- /dev/null +++ b/packages/events/EventTypes.js @@ -0,0 +1,37 @@ +/** + * 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 SyntheticEvent from 'events/SyntheticEvent'; +import type {AnyNativeEvent} from 'events/PluginModuleType'; + +export type EventResponderContext = { + event: AnyNativeEvent, + eventTarget: EventTarget, + eventType: string, + isPassive: () => boolean, + isPassiveSupported: () => boolean, + dispatchEvent: ( + name: string, + listener: (e: SyntheticEvent) => void | null, + pressTarget: EventTarget | null, + discrete: boolean, + extraProperties?: Object, + ) => void, + isTargetWithinElement: ( + childTarget: EventTarget, + parentTarget: EventTarget, + ) => boolean, + isTargetOwned: EventTarget => boolean, + isTargetWithinEventComponent: EventTarget => boolean, + isPositionWithinTouchHitTarget: (x: number, y: number) => boolean, + addRootEventTypes: (rootEventTypes: Array) => void, + removeRootEventTypes: (rootEventTypes: Array) => void, + requestOwnership: (target: EventTarget | null) => boolean, + releaseOwnership: (target: EventTarget | null) => boolean, +}; diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index bc1060d2d6df4..d3ba6ae8ee3e7 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -13,7 +13,7 @@ import {registrationNameModules} from 'events/EventPluginRegistry'; import warning from 'shared/warning'; import {canUseDOM} from 'shared/ExecutionEnvironment'; import warningWithoutStack from 'shared/warningWithoutStack'; -import type {ReactEventResponder} from 'shared/ReactTypes'; +import type {ReactEventResponderEventType} from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; import { @@ -1277,19 +1277,18 @@ export function restoreControlledState( } } -export function listenToEventResponderEvents( - eventResponder: ReactEventResponder, +export function listenToEventResponderEventTypes( + eventTypes: Array, element: Element | Document, ): void { if (enableEventAPI) { - const {targetEventTypes} = eventResponder; // Get the listening Set for this element. We use this to track // what events we're listening to. const listeningSet = getListeningSetForElement(element); // Go through each target event type of the event responder - for (let i = 0, length = targetEventTypes.length; i < length; ++i) { - const targetEventType = targetEventTypes[i]; + for (let i = 0, length = eventTypes.length; i < length; ++i) { + const targetEventType = eventTypes[i]; let topLevelType; let capture = false; let passive = true; @@ -1323,7 +1322,7 @@ export function listenToEventResponderEvents( // Create a unique name for this event, plus its properties. We'll // use this to ensure we don't listen to the same event with the same // properties again. - const passiveKey = passive ? '_passive' : ''; + const passiveKey = passive ? '_passive' : '_active'; const captureKey = capture ? '_capture' : ''; const listeningName = `${topLevelType}${passiveKey}${captureKey}`; if (!listeningSet.has(listeningName)) { diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 52541406190dd..97bfa60274dd0 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -24,7 +24,7 @@ import { warnForDeletedHydratableText, warnForInsertedHydratedElement, warnForInsertedHydratedText, - listenToEventResponderEvents, + listenToEventResponderEventTypes, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -864,7 +864,10 @@ export function handleEventComponent( ): void { if (enableEventAPI) { const rootElement = rootContainerInstance.ownerDocument; - listenToEventResponderEvents(eventResponder, rootElement); + listenToEventResponderEventTypes( + eventResponder.targetEventTypes, + rootElement, + ); } } diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 98969ac8a5b88..058d633fa0398 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -13,29 +13,39 @@ import { } from 'events/EventSystemFlags'; import type {AnyNativeEvent} from 'events/PluginModuleType'; import {EventComponent} from 'shared/ReactWorkTags'; -import type {ReactEventResponder} from 'shared/ReactTypes'; -import warning from 'shared/warning'; +import type { + ReactEventResponder, + ReactEventResponderEventType, +} from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; import SyntheticEvent from 'events/SyntheticEvent'; import {runEventsInBatch} from 'events/EventBatching'; import {interactiveUpdates} from 'events/ReactGenericBatching'; +import {executeDispatch} from 'events/EventPluginUtils'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; +import {listenToEventResponderEventTypes} from '../client/ReactDOMComponent'; import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; -// Event responders provide us an array of target event types. -// To ensure we fire the right responders for given events, we check -// if the incoming event type is actually relevant for an event -// responder. Instead of doing an O(n) lookup on the event responder -// target event types array each time, we instead create a Set for -// faster O(1) lookups. -export const eventResponderValidEventTypes: Map< - ReactEventResponder, +import {enableEventAPI} from 'shared/ReactFeatureFlags'; + +const rootEventTypesToEventComponents: Map< + DOMTopLevelEventType | string, + Set, +> = new Map(); +const targetEventTypeCached: Map< + Array, Set, > = new Map(); type EventListener = (event: SyntheticEvent) => void; +function copyEventProperties(eventData, syntheticEvent) { + for (let propName in eventData) { + syntheticEvent[propName] = eventData[propName]; + } +} + // TODO add context methods for dispatching events function DOMEventResponderContext( topLevelType: DOMTopLevelEventType, @@ -44,13 +54,14 @@ function DOMEventResponderContext( eventSystemFlags: EventSystemFlags, ) { this.event = nativeEvent; - this.eventType = topLevelType; this.eventTarget = nativeEventTarget; + this.eventType = topLevelType; this._flags = eventSystemFlags; this._fiber = null; this._responder = null; this._discreteEvents = null; this._nonDiscreteEvents = null; + this._isBatching = true; } DOMEventResponderContext.prototype.isPassive = function(): boolean { @@ -61,12 +72,6 @@ DOMEventResponderContext.prototype.isPassiveSupported = function(): boolean { return (this._flags & PASSIVE_NOT_SUPPORTED) === 0; }; -function copyEventProperties(eventData, syntheticEvent) { - for (let propName in eventData) { - syntheticEvent[propName] = eventData[propName]; - } -} - DOMEventResponderContext.prototype.dispatchEvent = function( eventName: string, eventListener: EventListener, @@ -88,80 +93,155 @@ DOMEventResponderContext.prototype.dispatchEvent = function( syntheticEvent._dispatchInstances = [eventTargetFiber]; syntheticEvent._dispatchListeners = [eventListener]; - let events; - if (discrete) { - events = this._discreteEvents; - if (events === null) { - events = this._discreteEvents = []; + if (this._isBatching) { + let events; + if (discrete) { + events = this._discreteEvents; + if (events === null) { + events = this._discreteEvents = []; + } + } else { + events = this._nonDiscreteEvents; + if (events === null) { + events = this._nonDiscreteEvents = []; + } } + events.push(syntheticEvent); } else { - events = this._nonDiscreteEvents; - if (events === null) { - events = this._nonDiscreteEvents = []; + if (discrete) { + interactiveUpdates(() => { + executeDispatch(syntheticEvent, eventListener, eventTargetFiber); + }); + } else { + executeDispatch(syntheticEvent, eventListener, eventTargetFiber); } } - events.push(syntheticEvent); }; -DOMEventResponderContext.prototype._runEventsInBatch = function(): void { - if (this._discreteEvents !== null) { - interactiveUpdates(() => { - runEventsInBatch(this._discreteEvents); - }); +DOMEventResponderContext.prototype.isTargetWithinEventComponent = function( + target: AnyNativeEvent, +): boolean { + const eventFiber = this._fiber; + + if (target != null) { + let fiber = getClosestInstanceFromNode(target); + while (fiber !== null) { + if (fiber === eventFiber || fiber === eventFiber.alternate) { + return true; + } + fiber = fiber.return; + } } - if (this._nonDiscreteEvents !== null) { - runEventsInBatch(this._nonDiscreteEvents); + return false; +}; + +DOMEventResponderContext.prototype.isTargetWithinElement = function( + childTarget: EventTarget, + parentTarget: EventTarget, +): boolean { + const childFiber = getClosestInstanceFromNode(childTarget); + const parentFiber = getClosestInstanceFromNode(parentTarget); + + let currentFiber = childFiber; + while (currentFiber !== null) { + if (currentFiber === parentFiber) { + return true; + } + currentFiber = currentFiber.return; } + return false; }; -function createValidEventTypeSet(targetEventTypes): Set { - const eventTypeSet = new Set(); - // Go through each target event type of the event responder - for (let i = 0, length = targetEventTypes.length; i < length; ++i) { - const targetEventType = targetEventTypes[i]; +DOMEventResponderContext.prototype.addRootEventTypes = function( + rootEventTypes: Array, +) { + const element = this.eventTarget.ownerDocument; + listenToEventResponderEventTypes(rootEventTypes, element); + const eventComponent = this._fiber; + for (let i = 0; i < rootEventTypes.length; i++) { + const rootEventType = rootEventTypes[i]; + const topLevelEventType = + typeof rootEventType === 'string' ? rootEventType : rootEventType.name; + let rootEventComponents = rootEventTypesToEventComponents.get( + topLevelEventType, + ); + if (rootEventComponents === undefined) { + rootEventComponents = new Set(); + rootEventTypesToEventComponents.set( + topLevelEventType, + rootEventComponents, + ); + } + rootEventComponents.add(eventComponent); + } +}; - if (typeof targetEventType === 'string') { - eventTypeSet.add(((targetEventType: any): DOMTopLevelEventType)); - } else { - if (__DEV__) { - warning( - typeof targetEventType === 'object' && targetEventType !== null, - 'Event Responder: invalid entry in targetEventTypes array. ' + - 'Entry must be string or an object. Instead, got %s.', - targetEventType, - ); - } - const targetEventConfigObject = ((targetEventType: any): { - name: DOMTopLevelEventType, - passive?: boolean, - capture?: boolean, - }); - eventTypeSet.add(targetEventConfigObject.name); +DOMEventResponderContext.prototype.removeRootEventTypes = function( + rootEventTypes: Array, +): void { + const eventComponent = this._fiber; + for (let i = 0; i < rootEventTypes.length; i++) { + const rootEventType = rootEventTypes[i]; + const topLevelEventType = + typeof rootEventType === 'string' ? rootEventType : rootEventType.name; + let rootEventComponents = rootEventTypesToEventComponents.get( + topLevelEventType, + ); + if (rootEventComponents !== undefined) { + rootEventComponents.delete(eventComponent); + } + } +}; + +DOMEventResponderContext.prototype.isPositionWithinTouchHitTarget = function() { + // TODO +}; + +DOMEventResponderContext.prototype.isTargetOwned = function() { + // TODO +}; + +DOMEventResponderContext.prototype.requestOwnership = function() { + // TODO +}; + +DOMEventResponderContext.prototype.releaseOwnership = function() { + // TODO +}; + +function getTargetEventTypes( + eventTypes: Array, +): Set { + let cachedSet = targetEventTypeCached.get(eventTypes); + + if (cachedSet === undefined) { + cachedSet = new Set(); + for (let i = 0; i < eventTypes.length; i++) { + const eventType = eventTypes[i]; + const topLevelEventType = + typeof eventType === 'string' ? eventType : eventType.name; + cachedSet.add(((topLevelEventType: any): DOMTopLevelEventType)); } + targetEventTypeCached.set(eventTypes, cachedSet); } - return eventTypeSet; + return cachedSet; } function handleTopLevelType( topLevelType: DOMTopLevelEventType, fiber: Fiber, context: Object, + isRootLevelEvent: boolean, ): void { const responder: ReactEventResponder = fiber.type.responder; - let {props, state} = fiber.stateNode; - let validEventTypesForResponder = eventResponderValidEventTypes.get( - responder, - ); - - if (validEventTypesForResponder === undefined) { - validEventTypesForResponder = createValidEventTypeSet( - responder.targetEventTypes, - ); - eventResponderValidEventTypes.set(responder, validEventTypesForResponder); - } - if (!validEventTypesForResponder.has(topLevelType)) { - return; + if (!isRootLevelEvent) { + // Validate the target event type exists on the responder + const targetEventTypes = getTargetEventTypes(responder.targetEventTypes); + if (!targetEventTypes.has(topLevelType)) { + return; + } } + let {props, state} = fiber.stateNode; if (state === null && responder.createInitialState !== undefined) { state = fiber.stateNode.state = responder.createInitialState(props); } @@ -177,19 +257,49 @@ export function runResponderEventsInBatch( nativeEventTarget: EventTarget, eventSystemFlags: EventSystemFlags, ): void { - const context = new DOMEventResponderContext( - topLevelType, - nativeEvent, - nativeEventTarget, - eventSystemFlags, - ); - let node = targetFiber; - // Traverse up the fiber tree till we find event component fibers. - while (node !== null) { - if (node.tag === EventComponent) { - handleTopLevelType(topLevelType, node, context); + if (enableEventAPI) { + const context = new DOMEventResponderContext( + topLevelType, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); + let node = targetFiber; + // Traverse up the fiber tree till we find event component fibers. + while (node !== null) { + if (node.tag === EventComponent) { + handleTopLevelType(topLevelType, node, context, false); + } + node = node.return; + } + // Handle root level events + const rootEventComponents = rootEventTypesToEventComponents.get( + topLevelType, + ); + if (rootEventComponents !== undefined) { + const rootEventComponentFibers = Array.from(rootEventComponents); + + for (let i = 0; i < rootEventComponentFibers.length; i++) { + const rootEventComponentFiber = rootEventComponentFibers[i]; + handleTopLevelType( + topLevelType, + rootEventComponentFiber, + context, + true, + ); + } + } + // Run batched events + const discreteEvents = context._discreteEvents; + if (discreteEvents !== null) { + interactiveUpdates(() => { + runEventsInBatch(discreteEvents); + }); + } + const nonDiscreteEvents = context._nonDiscreteEvents; + if (nonDiscreteEvents !== null) { + runEventsInBatch(nonDiscreteEvents); } - node = node.return; + context._isBatching = false; } - context._runEventsInBatch(); } diff --git a/packages/react-events/hover.js b/packages/react-events/hover.js new file mode 100644 index 0000000000000..a53675ca5c1ab --- /dev/null +++ b/packages/react-events/hover.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +'use strict'; + +const Hover = require('./src/Hover'); + +module.exports = Hover.default || Hover; diff --git a/packages/react-events/npm/hover.js b/packages/react-events/npm/hover.js new file mode 100644 index 0000000000000..1000d87449067 --- /dev/null +++ b/packages/react-events/npm/hover.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-events-hover.production.min.js'); +} else { + module.exports = require('./cjs/react-events-hover.development.js'); +} diff --git a/packages/react-events/npm/press.js b/packages/react-events/npm/press.js new file mode 100644 index 0000000000000..deaba326bba07 --- /dev/null +++ b/packages/react-events/npm/press.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-events-press.production.min.js'); +} else { + module.exports = require('./cjs/react-events-press.development.js'); +} diff --git a/packages/react-events/package.json b/packages/react-events/package.json index b680069e1153d..a61330b9ce455 100644 --- a/packages/react-events/package.json +++ b/packages/react-events/package.json @@ -11,6 +11,7 @@ "files": [ "LICENSE", "README.md", + "press.js", "build-info.json", "cjs/", "umd/" diff --git a/packages/react-events/press.js b/packages/react-events/press.js new file mode 100644 index 0000000000000..2add5ba8ed9d5 --- /dev/null +++ b/packages/react-events/press.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +'use strict'; + +const Press = require('./src/Press'); + +module.exports = Press.default || Press; diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js new file mode 100644 index 0000000000000..8cb78b77517bc --- /dev/null +++ b/packages/react-events/src/Hover.js @@ -0,0 +1,179 @@ +/** + * 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 {EventResponderContext} from 'events/EventTypes'; +import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; + +const targetEventTypes = [ + 'pointerover', + 'pointermove', + 'pointerout', + 'pointercancel', +]; + +type HoverState = { + isHovered: boolean, + isInHitSlop: boolean, + isTouched: boolean, +}; + +// In the case we don't have PointerEvents (Safari), we listen to touch events +// too +if (typeof window !== 'undefined' && window.PointerEvent === undefined) { + targetEventTypes.push('touchstart', 'mouseover', 'mouseout'); +} + +function dispatchHoverInEvents( + context: EventResponderContext, + props: Object, + state: HoverState, +): void { + const {event, eventTarget} = context; + if (props.onHoverChange) { + if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { + return; + } + if (props.onHoverIn) { + context.dispatchEvent('hoverin', props.onHoverIn, eventTarget, true); + } + const hoverChangeEventListener = () => { + props.onHoverChange(true); + }; + context.dispatchEvent( + 'hoverchange', + hoverChangeEventListener, + eventTarget, + true, + ); + } +} + +function dispatchHoverOutEvents(context: EventResponderContext, props: Object) { + const {event, eventTarget} = context; + if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { + return; + } + if (props.onHoverOut) { + context.dispatchEvent('hoverout', props.onHoverOut, eventTarget, true); + } + if (props.onHoverChange) { + const hoverChangeEventListener = () => { + props.onHoverChange(false); + }; + context.dispatchEvent( + 'hoverchange', + hoverChangeEventListener, + eventTarget, + true, + ); + } +} + +const HoverResponder = { + targetEventTypes, + createInitialState() { + return { + isHovered: false, + isInHitSlop: false, + isTouched: false, + }; + }, + handleEvent( + context: EventResponderContext, + props: Object, + state: HoverState, + ): void { + const {eventType, eventTarget, event} = context; + + switch (eventType) { + case 'touchstart': + // Touch devices don't have hover support + if (!state.isTouched) { + state.isTouched = true; + } + break; + case 'pointerover': + case 'mouseover': { + if ( + !state.isHovered && + !state.isTouched && + !context.isTargetOwned(eventTarget) + ) { + if ((event: any).pointerType === 'touch') { + state.isTouched = true; + return; + } + if ( + context.isPositionWithinTouchHitTarget( + (event: any).x, + (event: any).y, + ) + ) { + state.isInHitSlop = true; + return; + } + dispatchHoverInEvents(context, props, state); + state.isHovered = true; + } + break; + } + case 'pointerout': + case 'mouseout': { + if (state.isHovered && !state.isTouched) { + dispatchHoverOutEvents(context, props); + state.isHovered = false; + } + state.isInHitSlop = false; + state.isTouched = false; + break; + } + case 'pointermove': { + if (!state.isTouched) { + if (state.isInHitSlop) { + if ( + !context.isPositionWithinTouchHitTarget( + (event: any).x, + (event: any).y, + ) + ) { + dispatchHoverInEvents(context, props, state); + state.isHovered = true; + state.isInHitSlop = false; + } + } else if ( + state.isHovered && + context.isPositionWithinTouchHitTarget( + (event: any).x, + (event: any).y, + ) + ) { + dispatchHoverOutEvents(context, props); + state.isHovered = false; + state.isInHitSlop = true; + } + } + break; + } + case 'pointercancel': { + if (state.isHovered && !state.isTouched) { + dispatchHoverOutEvents(context, props); + state.isHovered = false; + state.isTouched = false; + } + break; + } + } + }, +}; + +export default { + $$typeof: REACT_EVENT_COMPONENT_TYPE, + props: null, + responder: HoverResponder, +}; diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js new file mode 100644 index 0000000000000..f4b9bd8e4d886 --- /dev/null +++ b/packages/react-events/src/Press.js @@ -0,0 +1,342 @@ +/** + * 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 {EventResponderContext} from 'events/EventTypes'; +import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; + +const targetEventTypes = [ + {name: 'click', passive: false}, + {name: 'keydown', passive: false}, + 'pointerdown', + 'pointercancel', + 'contextmenu', +]; +const rootEventTypes = ['pointerup', 'scroll']; + +// In the case we don't have PointerEvents (Safari), we listen to touch events +// too +if (typeof window !== 'undefined' && window.PointerEvent === undefined) { + targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel'); + rootEventTypes.push('mouseup'); +} + +type PressState = { + defaultPrevented: boolean, + isAnchorTouched: boolean, + isLongPressed: boolean, + isPressed: boolean, + longPressTimeout: null | TimeoutID, + pressTarget: null | EventTarget, + shouldSkipMouseAfterTouch: boolean, +}; + +function dispatchPressEvent( + context: EventResponderContext, + name: string, + state: PressState, + listener: (e: Object) => void, +): void { + context.dispatchEvent(name, listener, state.pressTarget, true); +} + +function dispatchPressInEvents( + context: EventResponderContext, + props: Object, + state: PressState, +): void { + if (props.onPressIn) { + context.dispatchEvent('pressin', props.onPressIn, state.pressTarget, true); + } + if (props.onPressChange) { + const pressChangeEventListener = () => { + props.onPressChange(true); + }; + context.dispatchEvent( + 'presschange', + pressChangeEventListener, + state.pressTarget, + true, + ); + } + if (!state.isLongPressed && (props.onLongPress || props.onLongPressChange)) { + const longPressDelay = props.longPressDelay || 1000; + state.longPressTimeout = setTimeout(() => { + state.isLongPressed = true; + state.longPressTimeout = null; + if (props.onLongPressChange) { + const longPressChangeEventListener = () => { + props.onLongPressChange(true); + }; + context.dispatchEvent( + 'longpresschange', + longPressChangeEventListener, + state.pressTarget, + true, + ); + } + }, longPressDelay); + } +} + +function dispatchPressOutEvents( + context: EventResponderContext, + props: Object, + state: PressState, +): void { + if (state.longPressTimeout !== null) { + clearTimeout(state.longPressTimeout); + state.longPressTimeout = null; + } + if (props.onPressOut) { + context.dispatchEvent( + 'pressout', + props.onPressOut, + state.pressTarget, + true, + ); + } + if (props.onPressChange) { + const pressChangeEventListener = () => { + props.onPressChange(false); + }; + context.dispatchEvent( + 'presschange', + pressChangeEventListener, + state.pressTarget, + true, + ); + } + if (props.onLongPressChange && state.isLongPressed) { + const longPressChangeEventListener = () => { + props.onLongPressChange(false); + }; + context.dispatchEvent( + 'longpresschange', + longPressChangeEventListener, + state.pressTarget, + true, + ); + } +} + +function isAnchorTagElement(eventTarget: EventTarget): boolean { + return (eventTarget: any).nodeName === 'A'; +} + +const PressResponder = { + targetEventTypes, + createInitialState(): PressState { + return { + defaultPrevented: false, + isAnchorTouched: false, + isLongPressed: false, + isPressed: false, + longPressTimeout: null, + pressTarget: null, + shouldSkipMouseAfterTouch: false, + }; + }, + handleEvent( + context: EventResponderContext, + props: Object, + state: PressState, + ): void { + const {eventTarget, eventType, event} = context; + + switch (eventType) { + case 'keydown': { + if (!props.onPress || context.isTargetOwned(eventTarget)) { + return; + } + const isValidKeyPress = + (event: any).which === 13 || + (event: any).which === 32 || + (event: any).keyCode === 13; + + if (!isValidKeyPress) { + return; + } + let keyPressEventListener = props.onPress; + + // Wrap listener with prevent default behaviour, unless + // we are dealing with an anchor. Anchor tags are special beacuse + // we need to use the "click" event, to properly allow browser + // heuristics for cancelling link clicks. Furthermore, iOS and + // Android can show previous of anchor tags that requires working + // with click rather than touch events (and mouse down/up). + if (!isAnchorTagElement(eventTarget)) { + keyPressEventListener = (e, key) => { + if (!e.isDefaultPrevented() && !e.nativeEvent.defaultPrevented) { + e.preventDefault(); + state.defaultPrevented = true; + props.onPress(e, key); + } + }; + } + dispatchPressEvent(context, 'press', state, keyPressEventListener); + break; + } + case 'touchstart': + // Touch events are for Safari, which lack pointer event support. + if (!state.isPressed && !context.isTargetOwned(eventTarget)) { + // We bail out of polyfilling anchor tags, given the same heuristics + // explained above in regards to needing to use click events. + if (isAnchorTagElement(eventTarget)) { + state.isAnchorTouched = true; + return; + } + state.pressTarget = eventTarget; + dispatchPressInEvents(context, props, state); + state.isPressed = true; + context.addRootEventTypes(rootEventTypes); + } + + break; + case 'touchend': { + // Touch events are for Safari, which lack pointer event support + if (state.isAnchorTouched) { + return; + } + if (state.isPressed) { + dispatchPressOutEvents(context, props, state); + if ( + eventType !== 'touchcancel' && + (props.onPress || props.onLongPress) + ) { + // Find if the X/Y of the end touch is still that of the original target + const changedTouch = (event: any).changedTouches[0]; + const doc = (eventTarget: any).ownerDocument; + const target = doc.elementFromPoint( + changedTouch.screenX, + changedTouch.screenY, + ); + if ( + target !== null && + context.isTargetWithinEventComponent(target) + ) { + if (state.isLongPressed && props.onLongPress) { + dispatchPressEvent( + context, + 'longpress', + state, + props.onLongPress, + ); + } else if (props.onPress) { + dispatchPressEvent(context, 'press', state, props.onPress); + } + } + } + state.isPressed = false; + state.isLongPressed = false; + state.shouldSkipMouseAfterTouch = true; + context.removeRootEventTypes(rootEventTypes); + } + break; + } + case 'pointerdown': + case 'mousedown': { + if ( + !state.isPressed && + !context.isTargetOwned(eventTarget) && + !state.shouldSkipMouseAfterTouch + ) { + if ((event: any).pointerType === 'mouse') { + // Ignore if we are pressing on hit slop area with mouse + if ( + context.isPositionWithinTouchHitTarget( + (event: any).x, + (event: any).y, + ) + ) { + return; + } + // Ignore right-clicks + if (event.button === 2 || event.button === 1) { + return; + } + } + state.pressTarget = eventTarget; + dispatchPressInEvents(context, props, state); + state.isPressed = true; + context.addRootEventTypes(rootEventTypes); + } + break; + } + case 'mouseup': + case 'pointerup': { + if (state.isPressed) { + if (state.shouldSkipMouseAfterTouch) { + state.shouldSkipMouseAfterTouch = false; + return; + } + dispatchPressOutEvents(context, props, state); + if ( + state.pressTarget !== null && + (props.onPress || props.onLongPress) + ) { + if (context.isTargetWithinElement(eventTarget, state.pressTarget)) { + if (state.isLongPressed && props.onLongPress) { + const longPressEventListener = e => { + props.onLongPress(e); + if (e.nativeEvent.defaultPrevented) { + state.defaultPrevented = true; + } + }; + dispatchPressEvent( + context, + 'longpress', + state, + longPressEventListener, + ); + } else if (props.onPress) { + const pressEventListener = (e, key) => { + props.onPress(e, key); + if (e.nativeEvent.defaultPrevented) { + state.defaultPrevented = true; + } + }; + dispatchPressEvent(context, 'press', state, pressEventListener); + } + } + } + state.isPressed = false; + state.isLongPressed = false; + context.removeRootEventTypes(rootEventTypes); + } + state.isAnchorTouched = false; + break; + } + case 'scroll': + case 'touchcancel': + case 'contextmenu': + case 'pointercancel': { + if (state.isPressed) { + state.shouldSkipMouseAfterTouch = false; + dispatchPressOutEvents(context, props, state); + state.isPressed = false; + state.isLongPressed = false; + context.removeRootEventTypes(rootEventTypes); + } + break; + } + case 'click': { + if (state.defaultPrevented) { + (event: any).preventDefault(); + state.defaultPrevented = false; + } + } + } + }, +}; + +export default { + $$typeof: REACT_EVENT_COMPONENT_TYPE, + props: null, + responder: PressResponder, +}; diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js new file mode 100644 index 0000000000000..1dc7cf5a8dc23 --- /dev/null +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -0,0 +1,129 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactDOM; +let Press; + +describe('Press event responder', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableEventAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + Press = require('react-events/press'); + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + it('should support onPress', () => { + let buttonRef = React.createRef(); + let events = []; + + function handleOnPress1() { + events.push('press 1'); + } + + function handleOnPress2() { + events.push('press 2'); + } + + function handleOnMouseDown() { + events.push('mousedown'); + } + + function handleKeyDown() { + events.push('keydown'); + } + + function Component() { + return ( + + + + + + ); + } + + ReactDOM.render(, container); + + const mouseDownEvent = document.createEvent('Event'); + mouseDownEvent.initEvent('mousedown', true, true); + buttonRef.current.dispatchEvent(mouseDownEvent); + + const mouseUpEvent = document.createEvent('Event'); + mouseUpEvent.initEvent('mouseup', true, true); + buttonRef.current.dispatchEvent(mouseUpEvent); + + expect(events).toEqual(['mousedown', 'press 2', 'press 1']); + + events = []; + const keyDownEvent = new KeyboardEvent('keydown', { + which: 13, + keyCode: 13, + bubbles: true, + cancelable: true, + }); + buttonRef.current.dispatchEvent(keyDownEvent); + + // press 1 should not occur as press 2 will preventDefault + expect(events).toEqual(['keydown', 'press 2']); + }); + + it('should support onPressIn and onPressOut', () => { + let divRef = React.createRef(); + let events = []; + + function handleOnPressIn() { + events.push('onPressIn'); + } + + function handleOnPressOut() { + events.push('onPressOut'); + } + + function Component() { + return ( + +
Press me!
+
+ ); + } + + ReactDOM.render(, container); + + const pointerEnterEvent = document.createEvent('Event'); + pointerEnterEvent.initEvent('pointerdown', true, true); + divRef.current.dispatchEvent(pointerEnterEvent); + + const pointerLeaveEvent = document.createEvent('Event'); + pointerLeaveEvent.initEvent('pointerup', true, true); + divRef.current.dispatchEvent(pointerLeaveEvent); + + expect(events).toEqual(['onPressIn', 'onPressOut']); + }); +}); diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 898c39728f98d..078a4697e52a3 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -464,12 +464,49 @@ const bundles = [ /******* React Events (experimental) *******/ { - bundleTypes: [NODE_DEV, NODE_PROD, FB_WWW_DEV, FB_WWW_PROD], + bundleTypes: [ + UMD_DEV, + UMD_PROD, + NODE_DEV, + NODE_PROD, + FB_WWW_DEV, + FB_WWW_PROD, + ], moduleType: ISOMORPHIC, entry: 'react-events', global: 'ReactEvents', externals: [], }, + + { + bundleTypes: [ + UMD_DEV, + UMD_PROD, + NODE_DEV, + NODE_PROD, + FB_WWW_DEV, + FB_WWW_PROD, + ], + moduleType: NON_FIBER_RENDERER, + entry: 'react-events/press', + global: 'ReactEventsPress', + externals: [], + }, + + { + bundleTypes: [ + UMD_DEV, + UMD_PROD, + NODE_DEV, + NODE_PROD, + FB_WWW_DEV, + FB_WWW_PROD, + ], + moduleType: NON_FIBER_RENDERER, + entry: 'react-events/hover', + global: 'ReactEventsHover', + externals: [], + }, ]; // Based on deep-freeze by substack (public domain) diff --git a/scripts/rollup/results.json b/scripts/rollup/results.json index 346e655064dbf..ddb87f522b2e0 100644 --- a/scripts/rollup/results.json +++ b/scripts/rollup/results.json @@ -46,29 +46,29 @@ "filename": "react-dom.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 803793, - "gzip": 183116 + "size": 832345, + "gzip": 188603 }, { "filename": "react-dom.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 107752, - "gzip": 34911 + "size": 107683, + "gzip": 34867 }, { "filename": "react-dom.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 813661, - "gzip": 184364 + "size": 826372, + "gzip": 186963 }, { "filename": "react-dom.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 108035, - "gzip": 34515 + "size": 107664, + "gzip": 34301 }, { "filename": "ReactDOM-dev.js", @@ -88,15 +88,15 @@ "filename": "react-dom-test-utils.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 48274, - "gzip": 13318 + "size": 48620, + "gzip": 13278 }, { "filename": "react-dom-test-utils.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 10511, - "gzip": 3880 + "size": 10184, + "gzip": 3732 }, { "filename": "react-dom-test-utils.development.js", @@ -123,22 +123,22 @@ "filename": "react-dom-unstable-native-dependencies.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 62061, - "gzip": 16285 + "size": 62190, + "gzip": 16206 }, { "filename": "react-dom-unstable-native-dependencies.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 11266, - "gzip": 3889 + "size": 10936, + "gzip": 3741 }, { "filename": "react-dom-unstable-native-dependencies.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 61643, - "gzip": 16033 + "size": 61854, + "gzip": 16078 }, { "filename": "react-dom-unstable-native-dependencies.production.min.js", @@ -165,29 +165,29 @@ "filename": "react-dom-server.browser.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 133264, - "gzip": 35517 + "size": 136840, + "gzip": 36205 }, { "filename": "react-dom-server.browser.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 19666, - "gzip": 7443 + "size": 19363, + "gzip": 7290 }, { "filename": "react-dom-server.browser.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 132758, - "gzip": 35195 + "size": 132878, + "gzip": 35237 }, { "filename": "react-dom-server.browser.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 19756, - "gzip": 7540 + "size": 19287, + "gzip": 7290 }, { "filename": "ReactDOMServer-dev.js", @@ -207,15 +207,15 @@ "filename": "react-dom-server.node.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 134747, - "gzip": 35752 + "size": 134867, + "gzip": 35791 }, { "filename": "react-dom-server.node.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 20639, - "gzip": 7850 + "size": 20170, + "gzip": 7598 }, { "filename": "react-art.development.js", @@ -515,50 +515,50 @@ "filename": "ReactDOM-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 821918, - "gzip": 182691 + "size": 851672, + "gzip": 188651 }, { "filename": "ReactDOM-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 333267, - "gzip": 61003 + "size": 339041, + "gzip": 62418 }, { "filename": "ReactTestUtils-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 44870, - "gzip": 12188 + "size": 46251, + "gzip": 12476 }, { "filename": "ReactDOMUnstableNativeDependencies-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 59002, - "gzip": 14967 + "size": 60296, + "gzip": 15251 }, { "filename": "ReactDOMUnstableNativeDependencies-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 26900, - "gzip": 5426 + "size": 26767, + "gzip": 5381 }, { "filename": "ReactDOMServer-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 130310, - "gzip": 33947 + "size": 135272, + "gzip": 35002 }, { "filename": "ReactDOMServer-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 46994, - "gzip": 10956 + "size": 46877, + "gzip": 10879 }, { "filename": "ReactART-dev.js", @@ -718,8 +718,8 @@ "filename": "react-dom.profiling.min.js", "bundleType": "NODE_PROFILING", "packageName": "react-dom", - "size": 111211, - "gzip": 35133 + "size": 110839, + "gzip": 34944 }, { "filename": "ReactNativeRenderer-profiling.js", @@ -767,8 +767,8 @@ "filename": "ReactDOM-profiling.js", "bundleType": "FB_WWW_PROFILING", "packageName": "react-dom", - "size": 339339, - "gzip": 62379 + "size": 345581, + "gzip": 63810 }, { "filename": "ReactNativeRenderer-profiling.js", @@ -795,8 +795,8 @@ "filename": "react-dom.profiling.min.js", "bundleType": "UMD_PROFILING", "packageName": "react-dom", - "size": 110829, - "gzip": 35621 + "size": 110730, + "gzip": 35485 }, { "filename": "scheduler-tracing.development.js", @@ -1033,64 +1033,64 @@ "filename": "react-dom-unstable-fire.development.js", "bundleType": "UMD_DEV", "packageName": "react-dom", - "size": 804147, - "gzip": 183253 + "size": 832471, + "gzip": 188731 }, { "filename": "react-dom-unstable-fire.production.min.js", "bundleType": "UMD_PROD", "packageName": "react-dom", - "size": 107767, - "gzip": 34920 + "size": 107698, + "gzip": 34877 }, { "filename": "react-dom-unstable-fire.profiling.min.js", "bundleType": "UMD_PROFILING", "packageName": "react-dom", - "size": 110844, - "gzip": 35630 + "size": 110745, + "gzip": 35493 }, { "filename": "react-dom-unstable-fire.development.js", "bundleType": "NODE_DEV", "packageName": "react-dom", - "size": 814014, - "gzip": 184503 + "size": 826725, + "gzip": 187104 }, { "filename": "react-dom-unstable-fire.production.min.js", "bundleType": "NODE_PROD", "packageName": "react-dom", - "size": 108049, - "gzip": 34524 + "size": 107678, + "gzip": 34310 }, { "filename": "react-dom-unstable-fire.profiling.min.js", "bundleType": "NODE_PROFILING", "packageName": "react-dom", - "size": 111225, - "gzip": 35142 + "size": 110853, + "gzip": 34953 }, { "filename": "ReactFire-dev.js", "bundleType": "FB_WWW_DEV", "packageName": "react-dom", - "size": 821109, - "gzip": 182607 + "size": 850863, + "gzip": 188551 }, { "filename": "ReactFire-prod.js", "bundleType": "FB_WWW_PROD", "packageName": "react-dom", - "size": 321531, - "gzip": 58583 + "size": 327881, + "gzip": 60185 }, { "filename": "ReactFire-profiling.js", "bundleType": "FB_WWW_PROFILING", "packageName": "react-dom", - "size": 327694, - "gzip": 59916 + "size": 334366, + "gzip": 61586 }, { "filename": "jest-mock-scheduler.development.js",