mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
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:
committed by
GitHub
parent
dd9cef9fc0
commit
7fc91f17c9
@@ -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
|
||||
|
||||
Vendored
+125
-5
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user