Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add event touch hit target hit slop logic to experimental event API #15261

Closed
wants to merge 16 commits into from
2 changes: 1 addition & 1 deletion packages/events/EventTypes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReactEventResponderEventType>,
) => void,
Expand Down
4 changes: 3 additions & 1 deletion packages/react-art/src/ReactARTHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,9 @@ export function handleEventComponent(

export function handleEventTarget(
type: Symbol | number,
props: Props,
lastProps: Props,
trueadm marked this conversation as resolved.
Show resolved Hide resolved
nextProps: Props,
rootContainerInstance: Container,
internalInstanceHandle: Object,
) {
// TODO: add handleEventTarget implementation
Expand Down
159 changes: 158 additions & 1 deletion packages/react-dom/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't work if the parentNamespace isn't HTML so you should throw if you don't intend to support other namespaces.

);
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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't drill into fibers at this level. We assume that host configs don't have access to fibers. These are supposed to be opaque types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't aware of this constraint. I can re-work it so that stateNode is an object ref instead. Then the object ref is passed to this function in host config; would that make more sense?

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';
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@necolas We need to come up with a good solution for this. We should mark this in the umbrella issue and track it as to how we can validate it and make sure stacking order etc is good.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't be resilient to the styles changing on the element. Updates to it might override this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this was a bit talking point with @necolas too. There's no cheap way to get around having to do a computed lookup to find what the position is. Do you have any ideas on how we might solve this problem?

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),
);
}
}
}
}
}
20 changes: 16 additions & 4 deletions packages/react-dom/src/client/ReactDOMComponentTree.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand All @@ -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;
Expand Down
36 changes: 18 additions & 18 deletions packages/react-dom/src/client/ReactDOMHostConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
warnForInsertedHydratedElement,
warnForInsertedHydratedText,
listenToEventResponderEventTypes,
handleEventTouchHitTarget,
} from './ReactDOMComponent';
import {getSelectionInformation, restoreSelection} from './ReactInputSelection';
import setTextContent from './setTextContent';
Expand All @@ -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 = {
Expand All @@ -62,22 +62,26 @@ 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;
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 | {|
isEventComponent?: boolean,
isEventTarget?: boolean,
|},
};
type HostContextProd = string;
export type HostContextProd = string;
export type HostContext = HostContextDev | HostContextProd;
export type UpdatePayload = Array<mixed>;
export type ChildSet = void; // Unused
Expand Down Expand Up @@ -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,
);
}
}
}
43 changes: 38 additions & 5 deletions packages/react-dom/src/events/DOMEventResponderSystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ReactEventResponderEventType>,
) {
Expand Down Expand Up @@ -201,10 +238,6 @@ DOMEventResponderContext.prototype.removeRootEventTypes = function(
}
};

DOMEventResponderContext.prototype.isPositionWithinTouchHitTarget = function() {
// TODO
};

DOMEventResponderContext.prototype.isTargetOwned = function(
targetElement: Element | Node,
): boolean {
Expand Down