diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js index a03ef8f7405..4a10f68285d 100644 --- a/packages/events/EventTypes.js +++ b/packages/events/EventTypes.js @@ -30,7 +30,7 @@ export type EventResponderContext = { ) => boolean, isTargetOwned: EventTarget => boolean, isTargetWithinEventComponent: EventTarget => boolean, - isPositionWithinTouchHitTarget: (x: number, y: number) => boolean, + isTargetPositionWithinHitSlop: (x: number, y: number) => boolean, addRootEventTypes: ( rootEventTypes: Array, ) => void, diff --git a/packages/react-art/src/ReactARTHostConfig.js b/packages/react-art/src/ReactARTHostConfig.js index caf2d5da18a..9c9ee3ee9b2 100644 --- a/packages/react-art/src/ReactARTHostConfig.js +++ b/packages/react-art/src/ReactARTHostConfig.js @@ -445,7 +445,9 @@ export function handleEventComponent( export function handleEventTarget( type: Symbol | number, - props: Props, + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, internalInstanceHandle: Object, ) { // TODO: add handleEventTarget implementation diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index bf5508c72cb..30e5110d813 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -15,7 +15,7 @@ import {canUseDOM} from 'shared/ExecutionEnvironment'; import warningWithoutStack from 'shared/warningWithoutStack'; import type {ReactEventResponderEventType} from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; -import {setListenToResponderEventTypes} from '../events/DOMEventResponderSystem'; +import getElementFromTouchHitTarget from 'shared/getElementFromTouchHitTarget'; import { getValueForAttribute, @@ -85,6 +85,15 @@ import possibleStandardNames from '../shared/possibleStandardNames'; import {validateProperties as validateARIAProperties} from '../shared/ReactDOMInvalidARIAHook'; import {validateProperties as validateInputProperties} from '../shared/ReactDOMNullInputValuePropHook'; import {validateProperties as validateUnknownProperties} from '../shared/ReactDOMUnknownPropertyHook'; +import {setListenToResponderEventTypes} from '../events/DOMEventResponderSystem'; +import {precacheFiberNode} from './ReactDOMComponentTree'; +import type { + Container, + HostContext, + HostContextDev, + HostContextProd, + Props, +} from './ReactDOMHostConfig'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; @@ -1343,3 +1352,151 @@ export function listenToEventResponderEventTypes( if (enableEventAPI) { setListenToResponderEventTypes(listenToEventResponderEventTypes); } + +const emptyObject = {}; + +export function createEventTargetHitSlop( + left: number | void | null, + right: number | void | null, + top: number | void | null, + bottom: number | void | null, + rootContainerInstance: Element | Document, + parentNamespace: string, +): Element { + const hitSlopElement = createElement( + 'div', + emptyObject, + rootContainerInstance, + parentNamespace, + ); + const hitSlopElementStyle = ((hitSlopElement: any): HTMLElement).style; + + hitSlopElementStyle.position = 'absolute'; + if (top != null) { + hitSlopElementStyle.top = `-${top}px`; + } + if (left != null) { + hitSlopElementStyle.left = `-${left}px`; + } + if (right != null) { + hitSlopElementStyle.right = `-${right}px`; + } + if (bottom != null) { + hitSlopElementStyle.bottom = `-${bottom}px`; + } + return hitSlopElement; +} + +export function diffAndUpdateEventTargetHitSlop( + left: number | null | void, + right: number | null | void, + top: number | null | void, + bottom: number | null | void, + lastProps: Props, + hitSlopElement: HTMLElement, +): void { + const hitSlopElementStyle = hitSlopElement.style; + if (lastProps.left !== left) { + if (left == null) { + hitSlopElementStyle.left = ''; + } else { + hitSlopElementStyle.left = `-${left}px`; + } + } + if (lastProps.right !== right) { + if (right == null) { + hitSlopElementStyle.right = ''; + } else { + hitSlopElementStyle.right = `-${right}px`; + } + } + if (lastProps.top !== top) { + if (top == null) { + hitSlopElementStyle.top = ''; + } else { + hitSlopElementStyle.top = `-${top}px`; + } + } + if (lastProps.bottom !== bottom) { + if (bottom == null) { + hitSlopElementStyle.bottom = ''; + } else { + hitSlopElementStyle.bottom = `-${bottom}px`; + } + } +} + +export function handleEventTouchHitTarget( + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, + internalInstanceHandle: Object, + hostContext: HostContext, +): void { + if (enableEventAPI) { + // Validates that there is a single element + const node = getElementFromTouchHitTarget(internalInstanceHandle); + if (node !== null) { + let parentNamespace: string; + if (__DEV__) { + const hostContextDev = ((hostContext: any): HostContextDev); + parentNamespace = hostContextDev.namespace; + warning( + parentNamespace === HTML_NAMESPACE, + 'An event touch hit target was used in an unsupported DOM namespace. ' + + 'Ensure the touch hit target is used in a HTML namespace.', + ); + } else { + parentNamespace = ((hostContext: any): HostContextProd); + } + + const element = ((node: any): HTMLElement); + // We update the event target state node to be that of the element. + // We can then diff this entry to determine if we need to add the + // hit slop element, or change the dimensions of the hit slop. + const lastElement = internalInstanceHandle.stateNode; + const left = nextProps.left; + const right = nextProps.right; + const top = nextProps.top; + const bottom = nextProps.bottom; + + if (lastElement !== element) { + if (left == null && right == null && top == null && bottom == null) { + return; + } + internalInstanceHandle.stateNode = element; + const hitSlopElement = createEventTargetHitSlop( + left, + right, + top, + bottom, + rootContainerInstance, + parentNamespace, + ); + // We need to make the target relative so we can make the hit slop + // element inside it absolutely position around the target. + // TODO add a dev check for the computed style and warn if it isn't + // compatible. + element.style.position = 'relative'; + element.appendChild(hitSlopElement); + precacheFiberNode(internalInstanceHandle, hitSlopElement); + } else { + // We appended the hit slop to the element, so it will always be the last child. + // TODO add a DEV validation warning to ensure this remains correct. + const hitSlopElement = element.lastChild; + + // Diff and update the sides of the hit slop + if (lastProps !== nextProps) { + diffAndUpdateEventTargetHitSlop( + left, + right, + top, + bottom, + lastProps, + ((hitSlopElement: any): HTMLElement), + ); + } + } + } + } +} diff --git a/packages/react-dom/src/client/ReactDOMComponentTree.js b/packages/react-dom/src/client/ReactDOMComponentTree.js index a3b61d6e49e..52cd9602432 100644 --- a/packages/react-dom/src/client/ReactDOMComponentTree.js +++ b/packages/react-dom/src/client/ReactDOMComponentTree.js @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import {HostComponent, HostText} from 'shared/ReactWorkTags'; +import {HostComponent, HostText, EventTarget} from 'shared/ReactWorkTags'; import invariant from 'shared/invariant'; const randomKey = Math.random() @@ -38,7 +38,11 @@ export function getClosestInstanceFromNode(node) { } let inst = node[internalInstanceKey]; - if (inst.tag === HostComponent || inst.tag === HostText) { + if ( + inst.tag === HostComponent || + inst.tag === HostText || + inst.tag === EventTarget + ) { // In Fiber, this will always be the deepest root. return inst; } @@ -53,7 +57,11 @@ export function getClosestInstanceFromNode(node) { export function getInstanceFromNode(node) { const inst = node[internalInstanceKey]; if (inst) { - if (inst.tag === HostComponent || inst.tag === HostText) { + if ( + inst.tag === HostComponent || + inst.tag === HostText || + inst.tag === EventTarget + ) { return inst; } else { return null; @@ -67,7 +75,11 @@ export function getInstanceFromNode(node) { * DOM node. */ export function getNodeFromInstance(inst) { - if (inst.tag === HostComponent || inst.tag === HostText) { + if ( + inst.tag === HostComponent || + inst.tag === HostText || + inst.tag === EventTarget + ) { // In Fiber this, is just the state node right now. We assume it will be // a host component or host text. return inst.stateNode; diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 14e80522b19..034894ad2be 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -25,6 +25,7 @@ import { warnForInsertedHydratedElement, warnForInsertedHydratedText, listenToEventResponderEventTypes, + handleEventTouchHitTarget, } from './ReactDOMComponent'; import {getSelectionInformation, restoreSelection} from './ReactInputSelection'; import setTextContent from './setTextContent'; @@ -50,7 +51,6 @@ import { REACT_EVENT_TARGET_TYPE, REACT_EVENT_TARGET_TOUCH_HIT, } from 'shared/ReactSymbols'; -import getElementFromTouchHitTarget from 'shared/getElementFromTouchHitTarget'; export type Type = string; export type Props = { @@ -62,6 +62,10 @@ export type Props = { style?: { display?: string, }, + bottom?: null | number, + left?: null | number, + right?: null | number, + top?: null | number, }; export type Container = Element | Document; export type Instance = Element; @@ -69,7 +73,7 @@ export type TextInstance = Text; export type SuspenseInstance = Comment & {_reactRetry?: () => void}; export type HydratableInstance = Instance | TextInstance | SuspenseInstance; export type PublicInstance = Element | Text; -type HostContextDev = { +export type HostContextDev = { namespace: string, ancestorInfo: mixed, eventData: null | {| @@ -77,7 +81,7 @@ type HostContextDev = { isEventTarget?: boolean, |}, }; -type HostContextProd = string; +export type HostContextProd = string; export type HostContext = HostContextDev | HostContextProd; export type UpdatePayload = Array; export type ChildSet = void; // Unused @@ -878,26 +882,22 @@ export function handleEventComponent( export function handleEventTarget( type: Symbol | number, - props: Props, + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, internalInstanceHandle: Object, + hostContext: HostContext, ): void { if (enableEventAPI) { // Touch target hit slop handling if (type === REACT_EVENT_TARGET_TOUCH_HIT) { - // Validates that there is a single element - const element = getElementFromTouchHitTarget(internalInstanceHandle); - if (element !== null) { - // We update the event target state node to be that of the element. - // We can then diff this entry to determine if we need to add the - // hit slop element, or change the dimensions of the hit slop. - const lastElement = internalInstanceHandle.stateNode; - if (lastElement !== element) { - internalInstanceHandle.stateNode = element; - // TODO: Create the hit slop element and attach it to the element - } else { - // TODO: Diff the left, top, right, bottom props - } - } + handleEventTouchHitTarget( + lastProps, + nextProps, + rootContainerInstance, + internalInstanceHandle, + hostContext, + ); } } } diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 9c52389654f..3918b677a14 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -12,7 +12,10 @@ import { PASSIVE_NOT_SUPPORTED, } from 'events/EventSystemFlags'; import type {AnyNativeEvent} from 'events/PluginModuleType'; -import {EventComponent} from 'shared/ReactWorkTags'; +import { + EventComponent, + EventTarget as EventTargetWorkTag, +} from 'shared/ReactWorkTags'; import type { ReactEventResponder, ReactEventResponderEventType, @@ -160,6 +163,40 @@ DOMEventResponderContext.prototype.isTargetWithinElement = function( return false; }; +DOMEventResponderContext.prototype.isTargetPositionWithinHitSlop = function( + x: number, + y: number, +): boolean { + const doc = this.eventTarget.ownerDocument; + // This isn't available in some environments (JSDOM) + if (typeof doc.elementFromPoint !== 'function') { + return false; + } + const target = doc.elementFromPoint(x, y); + if (target === null) { + return false; + } + const childFiber = getClosestInstanceFromNode(target); + if (childFiber === null) { + return false; + } + if (childFiber.tag === EventTargetWorkTag) { + // TODO find another way to do this without using the + // expensive getBoundingClientRect. + const { + left, + top, + right, + bottom, + } = target.parentNode.getBoundingClientRect(); + if (x > left && y > top && x < right && y < bottom) { + return false; + } + return true; + } + return false; +}; + DOMEventResponderContext.prototype.addRootEventTypes = function( rootEventTypes: Array, ) { @@ -201,10 +238,6 @@ DOMEventResponderContext.prototype.removeRootEventTypes = function( } }; -DOMEventResponderContext.prototype.isPositionWithinTouchHitTarget = function() { - // TODO -}; - DOMEventResponderContext.prototype.isTargetOwned = function( targetElement: Element | Node, ): boolean { diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index f8585fae8cd..170df3dcc78 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -110,7 +110,7 @@ const HoverResponder = { return; } if ( - context.isPositionWithinTouchHitTarget( + context.isTargetPositionWithinHitSlop( (event: any).x, (event: any).y, ) @@ -137,7 +137,7 @@ const HoverResponder = { if (!state.isTouched) { if (state.isInHitSlop) { if ( - !context.isPositionWithinTouchHitTarget( + !context.isTargetPositionWithinHitSlop( (event: any).x, (event: any).y, ) @@ -148,7 +148,7 @@ const HoverResponder = { } } else if ( state.isHovered && - context.isPositionWithinTouchHitTarget( + context.isTargetPositionWithinHitSlop( (event: any).x, (event: any).y, ) diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 03203ded48c..d4e92850254 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -13,7 +13,7 @@ import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; const targetEventTypes = [ {name: 'click', passive: false}, {name: 'keydown', passive: false}, - 'pointerdown', + {name: 'pointerdown', passive: false}, 'pointercancel', 'contextmenu', ]; @@ -263,11 +263,13 @@ const PressResponder = { if ((event: any).pointerType === 'mouse') { // Ignore if we are pressing on hit slop area with mouse if ( - context.isPositionWithinTouchHitTarget( + context.isTargetPositionWithinHitSlop( (event: any).x, (event: any).y, ) ) { + // This ensures that focus isn't incorrectly triggered + (event: any).preventDefault(); return; } // Ignore middle- and right-clicks diff --git a/packages/react-native-renderer/src/ReactFabricHostConfig.js b/packages/react-native-renderer/src/ReactFabricHostConfig.js index bc673062307..e58ce8388c9 100644 --- a/packages/react-native-renderer/src/ReactFabricHostConfig.js +++ b/packages/react-native-renderer/src/ReactFabricHostConfig.js @@ -437,8 +437,11 @@ export function handleEventComponent( export function handleEventTarget( type: Symbol | number, - props: Props, + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, internalInstanceHandle: Object, + hostContext: HostContext, ) { // TODO: add handleEventTarget implementation } diff --git a/packages/react-native-renderer/src/ReactNativeHostConfig.js b/packages/react-native-renderer/src/ReactNativeHostConfig.js index 27044ef54df..6a473a242fd 100644 --- a/packages/react-native-renderer/src/ReactNativeHostConfig.js +++ b/packages/react-native-renderer/src/ReactNativeHostConfig.js @@ -496,8 +496,11 @@ export function handleEventComponent( export function handleEventTarget( type: Symbol | number, - props: Props, + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, internalInstanceHandle: Object, + hostContext: HostContext, ) { // TODO: add handleEventTarget implementation } diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index 585406898e2..df2cf2d9d15 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -418,7 +418,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { handleEventTarget( type: Symbol | number, - props: Props, + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, internalInstanceHandle: Object, ) { if (type === REACT_EVENT_TARGET_TOUCH_HIT) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.js b/packages/react-reconciler/src/ReactFiberCompleteWork.js index 1a0fd302322..1c14cefd896 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.js @@ -91,6 +91,8 @@ import { enableEventAPI, } from 'shared/ReactFeatureFlags'; +const emptyObject = {}; + function markUpdate(workInProgress: Fiber) { // Tag the fiber with an update effect. This turns a Placement into // a PlacementAndUpdate. @@ -783,8 +785,18 @@ function completeWork( case EventTarget: { if (enableEventAPI) { popHostContext(workInProgress); + const rootContainerInstance = getRootHostContainer(); + const currentHostContext = getHostContext(); const type = workInProgress.type.type; - handleEventTarget(type, newProps, workInProgress); + const oldProps = current !== null ? current.memoizedProps : emptyObject; + handleEventTarget( + type, + oldProps, + newProps, + rootContainerInstance, + workInProgress, + currentHostContext, + ); } break; } diff --git a/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js b/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js index 9a697379d3b..1e41ebe0697 100644 --- a/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js +++ b/packages/react-reconciler/src/__tests__/ReactFiberEvents-test-internal.js @@ -1128,6 +1128,41 @@ describe('ReactFiberEvents', () => { 'Warning: validateDOMNesting: React event targets must not have event components as children.', ); }); + + it('should render a simple event component with a single event target (hit slop)', () => { + const Test = () => ( + + +
Hello world
+
+
+ ); + + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + '
' + + 'Hello world
', + ); + + const Test2 = () => ( + + + I am now a span + + + ); + + ReactDOM.render(, container); + expect(Scheduler).toFlushWithoutYielding(); + expect(container.innerHTML).toBe( + 'I am now a span' + + '
', + ); + }); }); describe('ReactDOMServer', () => { diff --git a/packages/react-test-renderer/src/ReactTestHostConfig.js b/packages/react-test-renderer/src/ReactTestHostConfig.js index a5ac30273ba..aec2d2db463 100644 --- a/packages/react-test-renderer/src/ReactTestHostConfig.js +++ b/packages/react-test-renderer/src/ReactTestHostConfig.js @@ -319,8 +319,11 @@ export function handleEventComponent( export function handleEventTarget( type: Symbol | number, - props: Props, + lastProps: Props, + nextProps: Props, + rootContainerInstance: Container, internalInstanceHandle: Object, + hostContext: HostContext, ) { if (type === REACT_EVENT_TARGET_TOUCH_HIT) { // Validates that there is a single element