React events: add onPressMove and pressRetentionOffset to Press (#15374)

This implementation differs from equivalents in React Native in the following ways:

1. A move during a press will not cancel onLongPress.
2. A move to outside the retention target will cancel the press and not
reactivate when moved back within the retention target.
This commit is contained in:
Nicolas Gallagher
2019-04-10 10:52:50 -07:00
committed by GitHub
parent dd9cef9fc0
commit 7fc91f17c9
3 changed files with 378 additions and 41 deletions
+13 -6
View File
@@ -4,7 +4,7 @@
events API that is not available in open source builds.*
Event components do not render a host node. They listen to native browser events
dispatched on the host node of their child and transform those events into
dispatched on the host node of their child and transform those events into
high-level events for applications.
@@ -176,7 +176,8 @@ Disables all `Press` events.
### onLongPress: (e: PressEvent) => void
Called once the element has been pressed for the length of `delayLongPress`.
Called once the element has been pressed for the length of `delayLongPress`. If
the press point moves more than 10px `onLongPress` is cancelled.
### onLongPressChange: boolean => void
@@ -202,9 +203,15 @@ Called when the element changes press state (i.e., after `onPressStart` and
### onPressEnd: (e: PressEvent) => void
Called once the element is no longer pressed. If the press starts again before
the `delayPressEnd` threshold is exceeded then the delay is reset to prevent
`onPressEnd` being called during a press.
Called once the element is no longer pressed (because it was released, or moved
beyond the hit bounds). If the press starts again before the `delayPressEnd`
threshold is exceeded then the delay is reset to prevent `onPressEnd` being
called during a press.
### onPressMove: (e: PressEvent) => void
Called when an active press moves within the hit bounds of the element. Never
called for keyboard-initiated press events.
### onPressStart: (e: PressEvent) => void
@@ -212,7 +219,7 @@ Called once the element is pressed down. If the press is released before the
`delayPressStart` threshold is exceeded then the delay is cut short and
`onPressStart` is called immediately.
### pressRententionOffset: PressOffset
### pressRetentionOffset: PressOffset
Defines how far the pointer (while held down) may move outside the bounds of the
element before it is deactivated. Once deactivated, the pointer (still held
+125 -5
View File
@@ -24,8 +24,14 @@ type PressProps = {
onPress: (e: PressEvent) => void,
onPressChange: boolean => void,
onPressEnd: (e: PressEvent) => void,
onPressMove: (e: PressEvent) => void,
onPressStart: (e: PressEvent) => void,
pressRententionOffset: Object,
pressRetentionOffset: {
top: number,
right: number,
bottom: number,
left: number,
},
};
type PressState = {
@@ -35,15 +41,23 @@ type PressState = {
isAnchorTouched: boolean,
isLongPressed: boolean,
isPressed: boolean,
isPressWithinResponderRegion: boolean,
longPressTimeout: null | TimeoutID,
pressTarget: null | Element | Document,
pressEndTimeout: null | TimeoutID,
pressStartTimeout: null | TimeoutID,
responderRegion: null | $ReadOnly<{|
bottom: number,
left: number,
right: number,
top: number,
|}>,
shouldSkipMouseAfterTouch: boolean,
};
type PressEventType =
| 'press'
| 'pressmove'
| 'pressstart'
| 'pressend'
| 'presschange'
@@ -59,6 +73,12 @@ type PressEvent = {|
const DEFAULT_PRESS_END_DELAY_MS = 0;
const DEFAULT_PRESS_START_DELAY_MS = 0;
const DEFAULT_LONG_PRESS_DELAY_MS = 500;
const DEFAULT_PRESS_RETENTION_OFFSET = {
bottom: 20,
top: 20,
left: 20,
right: 20,
};
const targetEventTypes = [
{name: 'click', passive: false},
@@ -70,13 +90,18 @@ const targetEventTypes = [
const rootEventTypes = [
{name: 'keyup', passive: false},
{name: 'pointerup', passive: false},
'pointermove',
'scroll',
];
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel');
rootEventTypes.push({name: 'mouseup', passive: false});
targetEventTypes.push('touchstart', 'touchend', 'touchcancel', 'mousedown');
rootEventTypes.push(
{name: 'mouseup', passive: false},
'touchmove',
'mousemove',
);
}
function createPressEvent(
@@ -232,8 +257,11 @@ function dispatchPressEndEvents(
if (!wasActivePressStart && state.pressStartTimeout !== null) {
clearTimeout(state.pressStartTimeout);
state.pressStartTimeout = null;
// if we haven't yet activated (due to delays), activate now
activate(context, props, state);
// don't activate if a press has moved beyond the responder region
if (state.isPressWithinResponderRegion) {
// if we haven't yet activated (due to delays), activate now
activate(context, props, state);
}
}
if (state.isActivePressed) {
@@ -267,6 +295,59 @@ function calculateDelayMS(delay: ?number, min = 0, fallback = 0) {
return Math.max(min, maybeNumber != null ? maybeNumber : fallback);
}
// TODO: account for touch hit slop
function calculateResponderRegion(target, props) {
const pressRetentionOffset = {
...DEFAULT_PRESS_RETENTION_OFFSET,
...props.pressRetentionOffset,
};
const clientRect = target.getBoundingClientRect();
let bottom = clientRect.bottom;
let left = clientRect.left;
let right = clientRect.right;
let top = clientRect.top;
if (pressRetentionOffset) {
if (pressRetentionOffset.bottom != null) {
bottom += pressRetentionOffset.bottom;
}
if (pressRetentionOffset.left != null) {
left -= pressRetentionOffset.left;
}
if (pressRetentionOffset.right != null) {
right += pressRetentionOffset.right;
}
if (pressRetentionOffset.top != null) {
top -= pressRetentionOffset.top;
}
}
return {
bottom,
top,
left,
right,
};
}
function isPressWithinResponderRegion(
nativeEvent: $PropertyType<ResponderEvent, 'nativeEvent'>,
state: PressState,
): boolean {
const {responderRegion} = state;
const event = (nativeEvent: any);
return (
responderRegion != null &&
(event.pageX >= responderRegion.left &&
event.pageX <= responderRegion.right &&
event.pageY >= responderRegion.top &&
event.pageY <= responderRegion.bottom)
);
}
function unmountResponder(
context: ReactResponderContext,
props: PressProps,
@@ -288,10 +369,12 @@ const PressResponder = {
isAnchorTouched: false,
isLongPressed: false,
isPressed: false,
isPressWithinResponderRegion: true,
longPressTimeout: null,
pressEndTimeout: null,
pressStartTimeout: null,
pressTarget: null,
responderRegion: null,
shouldSkipMouseAfterTouch: false,
};
},
@@ -333,11 +416,46 @@ const PressResponder = {
}
}
state.pressTarget = target;
state.isPressWithinResponderRegion = true;
dispatchPressStartEvents(context, props, state);
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
}
break;
}
case 'pointermove':
case 'mousemove':
case 'touchmove': {
if (state.isPressed) {
if (state.shouldSkipMouseAfterTouch) {
return;
}
if (state.responderRegion == null) {
let currentTarget = (target: any);
while (
currentTarget.parentNode &&
context.isTargetWithinEventComponent(currentTarget.parentNode)
) {
currentTarget = currentTarget.parentNode;
}
state.responderRegion = calculateResponderRegion(
currentTarget,
props,
);
}
if (isPressWithinResponderRegion(nativeEvent, state)) {
state.isPressWithinResponderRegion = true;
if (props.onPressMove) {
dispatchEvent(context, state, 'pressmove', props.onPressMove);
}
} else {
state.isPressWithinResponderRegion = false;
dispatchPressEndEvents(context, props, state);
}
}
break;
}
case 'pointerup':
case 'mouseup': {
if (state.isPressed) {
@@ -373,6 +491,7 @@ const PressResponder = {
context.removeRootEventTypes(rootEventTypes);
}
state.isAnchorTouched = false;
state.shouldSkipMouseAfterTouch = false;
break;
}
@@ -389,6 +508,7 @@ const PressResponder = {
return;
}
state.pressTarget = target;
state.isPressWithinResponderRegion = true;
dispatchPressStartEvents(context, props, state);
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
}
@@ -16,9 +16,14 @@ let Press;
const DEFAULT_LONG_PRESS_DELAY = 500;
const createPointerEvent = type => {
const event = document.createEvent('Event');
event.initEvent(type, true, true);
const createPointerEvent = (type, data) => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent(type, true, true);
if (data != null) {
Object.entries(data).forEach(([key, value]) => {
event[key] = value;
});
}
return event;
};
@@ -592,36 +597,241 @@ describe('Event responder: Press', () => {
});
});
// TODO
//describe('`onPress*` with movement', () => {
//describe('within bounds of hit rect', () => {
/** ┌──────────────────┐
* │ ┌────────────┐ │
* │ │ VisualRect │ │
* │ └────────────┘ │
* │ HitRect X │ <= Move to X and release
* └──────────────────┘
*/
describe('press with movement', () => {
const rectMock = {
width: 100,
height: 100,
top: 50,
left: 50,
right: 500,
bottom: 500,
};
const pressRectOffset = 20;
const getBoundingClientRectMock = () => rectMock;
const coordinatesInside = {
pageX: rectMock.left - pressRectOffset,
pageY: rectMock.top - pressRectOffset,
};
const coordinatesOutside = {
pageX: rectMock.left - pressRectOffset - 1,
pageY: rectMock.top - pressRectOffset - 1,
};
//it('"onPress*" events are called when no delay', () => {});
//it('"onPress*" events are called after a delay', () => {});
//});
describe('within bounds of hit rect', () => {
/** ┌──────────────────┐
* │ ┌────────────┐ │
* │ │ VisualRect │ │
* │ └────────────┘ │
* │ HitRect X │ <= Move to X and release
* └──────────────────┘
*/
it('no delay and "onPress*" events are called immediately', () => {
let events = [];
const ref = React.createRef();
const createEventHandler = msg => () => {
events.push(msg);
};
//describe('beyond bounds of hit rect', () => {
/** ┌──────────────────┐
* │ ┌────────────┐ │
* │ │ VisualRect │ │
* │ └────────────┘ │
* │ HitRect │
* └──────────────────┘
* X <= Move to X and release
*/
const element = (
<Press
onPress={createEventHandler('onPress')}
onPressChange={createEventHandler('onPressChange')}
onPressMove={createEventHandler('onPressMove')}
onPressStart={createEventHandler('onPressStart')}
onPressEnd={createEventHandler('onPressEnd')}>
<div ref={ref} />
</Press>
);
//it('"onPress" only is not called when no delay', () => {});
//it('"onPress*" events are not called after a delay', () => {});
//it('"onPress*" events are called when press is released before measure completes', () => {});
//});
//});
ReactDOM.render(element, container);
ref.current.getBoundingClientRect = getBoundingClientRectMock;
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
jest.runAllTimers();
expect(events).toEqual([
'onPressStart',
'onPressChange',
'onPressMove',
'onPressEnd',
'onPressChange',
'onPress',
]);
});
it('delay and "onPressMove" is called before "onPress*" events', () => {
let events = [];
const ref = React.createRef();
const createEventHandler = msg => () => {
events.push(msg);
};
const element = (
<Press
delayPressStart={500}
onPress={createEventHandler('onPress')}
onPressChange={createEventHandler('onPressChange')}
onPressMove={createEventHandler('onPressMove')}
onPressStart={createEventHandler('onPressStart')}
onPressEnd={createEventHandler('onPressEnd')}>
<div ref={ref} />
</Press>
);
ReactDOM.render(element, container);
ref.current.getBoundingClientRect = getBoundingClientRectMock;
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
jest.advanceTimersByTime(499);
expect(events).toEqual(['onPressMove']);
events = [];
jest.advanceTimersByTime(1);
expect(events).toEqual(['onPressStart', 'onPressChange']);
events = [];
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(events).toEqual(['onPressEnd', 'onPressChange', 'onPress']);
});
it('press retention offset can be configured', () => {
let events = [];
const ref = React.createRef();
const createEventHandler = msg => () => {
events.push(msg);
};
const pressRetentionOffset = {top: 40, bottom: 40, left: 40, right: 40};
const element = (
<Press
pressRetentionOffset={pressRetentionOffset}
onPress={createEventHandler('onPress')}
onPressChange={createEventHandler('onPressChange')}
onPressMove={createEventHandler('onPressMove')}
onPressStart={createEventHandler('onPressStart')}
onPressEnd={createEventHandler('onPressEnd')}>
<div ref={ref} />
</Press>
);
ReactDOM.render(element, container);
ref.current.getBoundingClientRect = getBoundingClientRectMock;
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(
createPointerEvent('pointermove', {
pageX: rectMock.left - pressRetentionOffset.left,
pageY: rectMock.top - pressRetentionOffset.top,
}),
);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(events).toEqual([
'onPressStart',
'onPressChange',
'onPressMove',
'onPressEnd',
'onPressChange',
'onPress',
]);
});
});
describe('beyond bounds of hit rect', () => {
/** ┌──────────────────┐
* │ ┌────────────┐ │
* │ │ VisualRect │ │
* │ └────────────┘ │
* │ HitRect │
* └──────────────────┘
* X <= Move to X and release
*/
it('"onPress" is not called on release', () => {
let events = [];
const ref = React.createRef();
const createEventHandler = msg => () => {
events.push(msg);
};
const element = (
<Press
onPress={createEventHandler('onPress')}
onPressChange={createEventHandler('onPressChange')}
onPressMove={createEventHandler('onPressMove')}
onPressStart={createEventHandler('onPressStart')}
onPressEnd={createEventHandler('onPressEnd')}>
<div ref={ref} />
</Press>
);
ReactDOM.render(element, container);
ref.current.getBoundingClientRect = getBoundingClientRectMock;
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesOutside),
);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
jest.runAllTimers();
expect(events).toEqual([
'onPressStart',
'onPressChange',
'onPressMove',
'onPressEnd',
'onPressChange',
]);
});
it('"onPress*" events are not called after delay expires', () => {
let events = [];
const ref = React.createRef();
const createEventHandler = msg => () => {
events.push(msg);
};
const element = (
<Press
delayPressStart={500}
delayPressEnd={500}
onLongPress={createEventHandler('onLongPress')}
onPress={createEventHandler('onPress')}
onPressChange={createEventHandler('onPressChange')}
onPressMove={createEventHandler('onPressMove')}
onPressStart={createEventHandler('onPressStart')}
onPressEnd={createEventHandler('onPressEnd')}>
<div ref={ref} />
</Press>
);
ReactDOM.render(element, container);
ref.current.getBoundingClientRect = getBoundingClientRectMock;
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesInside),
);
ref.current.dispatchEvent(
createPointerEvent('pointermove', coordinatesOutside),
);
jest.runAllTimers();
expect(events).toEqual(['onPressMove']);
events = [];
ref.current.dispatchEvent(createPointerEvent('pointerup'));
jest.runAllTimers();
expect(events).toEqual([]);
});
});
});
describe('delayed and multiple events', () => {
it('dispatches in the correct order', () => {