From 7fc91f17c99fbbd1497737fb8cbd8f45cbfd6d48 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Wed, 10 Apr 2019 10:52:50 -0700 Subject: [PATCH] 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. --- packages/react-events/README.md | 19 +- packages/react-events/src/Press.js | 130 ++++++++- .../src/__tests__/Press-test.internal.js | 276 +++++++++++++++--- 3 files changed, 381 insertions(+), 44 deletions(-) diff --git a/packages/react-events/README.md b/packages/react-events/README.md index 9173084e54a5c..cf607cd7f790e 100644 --- a/packages/react-events/README.md +++ b/packages/react-events/README.md @@ -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 diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index b20acf52327ac..3374fab65261a 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -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, + 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); } diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 2c83bcaafd338..8367c46c37b03 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -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 - * └──────────────────┘ - */ - - //it('"onPress*" events are called when no delay', () => {}); - //it('"onPress*" events are called after a delay', () => {}); - //}); - - //describe('beyond bounds of hit rect', () => { - /** ┌──────────────────┐ - * │ ┌────────────┐ │ - * │ │ VisualRect │ │ - * │ └────────────┘ │ - * │ HitRect │ - * └──────────────────┘ - * X <= Move to X and release - */ - - //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', () => {}); - //}); - //}); + 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, + }; + + 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); + }; + + const element = ( + +
+ + ); + + 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 = ( + +
+ + ); + + 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 = ( + +
+ + ); + + 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 = ( + +
+ + ); + + 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 = ( + +
+ + ); + + 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', () => {