diff --git a/packages/react-events/README.md b/packages/react-events/README.md index cf607cd7f790e..57157f720dfaf 100644 --- a/packages/react-events/README.md +++ b/packages/react-events/README.md @@ -28,7 +28,9 @@ const TextField = (props) => ( ```js // Types -type FocusEvent = {} +type FocusEvent = { + type: 'blur' | 'focus' | 'focuschange' +} ``` ### disabled: boolean @@ -76,7 +78,10 @@ const Link = (props) => ( ```js // Types -type HoverEvent = {} +type HoverEvent = { + pointerType: 'mouse', + type: 'hoverstart' | 'hoverend' | 'hovermove' | 'hoverchange' +} ``` ### delayHoverEnd: number @@ -103,12 +108,25 @@ Called when the element changes hover state (i.e., after `onHoverStart` and Called once the element is no longer hovered. It will be cancelled if the pointer leaves the element before the `delayHoverStart` threshold is exceeded. +### onHoverMove: (e: HoverEvent) => void + +Called when the pointer moves within the hit bounds of the element. `onHoverMove` is +called immediately and doesn't wait for delayed `onHoverStart`. + ### onHoverStart: (e: HoverEvent) => void Called once the element is hovered. It will not be called if the pointer leaves the element before the `delayHoverStart` threshold is exceeded. And it will not be called more than once before `onHoverEnd` is called. +### preventDefault: boolean = true + +Whether to `preventDefault()` native events. + +### stopPropagation: boolean = true + +Whether to `stopPropagation()` native events. + ## Press @@ -145,7 +163,10 @@ const Button = (props) => ( ```js // Types -type PressEvent = {} +type PressEvent = { + pointerType: 'mouse' | 'touch' | 'pen' | 'keyboard', + type: 'press' | 'pressstart' | 'pressend' | 'presschange' | 'pressmove' | 'longpress' | 'longpresschange' +} type PressOffset = { top: number, @@ -210,8 +231,9 @@ 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. +Called when a press moves within the hit bounds of the element. `onPressMove` is +called immediately and doesn't wait for delayed `onPressStart`. Never called for +keyboard-initiated press events. ### onPressStart: (e: PressEvent) => void @@ -225,3 +247,11 @@ 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 down) can be moved back within the bounds of the element to reactivate it. Ensure you pass in a constant to reduce memory allocations. + +### preventDefault: boolean = true + +Whether to `preventDefault()` native events. + +### stopPropagation: boolean = true + +Whether to `stopPropagation()` native events. diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index de4bcd2f41c18..60b90676d8b78 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -19,6 +19,7 @@ type HoverProps = { delayHoverStart: number, onHoverChange: boolean => void, onHoverEnd: (e: HoverEvent) => void, + onHoverMove: (e: HoverEvent) => void, onHoverStart: (e: HoverEvent) => void, }; @@ -29,9 +30,10 @@ type HoverState = { isTouched: boolean, hoverStartTimeout: null | Symbol, hoverEndTimeout: null | Symbol, + skipMouseAfterPointer: boolean, }; -type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; +type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove'; type HoverEvent = {| listener: HoverEvent => void, @@ -51,7 +53,7 @@ const targetEventTypes = [ // 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', 'mouseover', 'mouseout'); + targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout'); } function createHoverEvent( @@ -200,6 +202,7 @@ const HoverResponder = { isTouched: false, hoverStartTimeout: null, hoverEndTimeout: null, + skipMouseAfterPointer: false, }; }, onEvent( @@ -228,6 +231,9 @@ const HoverResponder = { state.isTouched = true; return; } + if (type === 'pointerover') { + state.skipMouseAfterPointer = true; + } if ( context.isPositionWithinTouchHitTarget( target.ownerDocument, @@ -249,10 +255,16 @@ const HoverResponder = { } state.isInHitSlop = false; state.isTouched = false; + state.skipMouseAfterPointer = false; break; } - case 'pointermove': { + case 'pointermove': + case 'mousemove': { + if (type === 'mousemove' && state.skipMouseAfterPointer === true) { + return; + } + if (state.isHovered && !state.isTouched) { if (state.isInHitSlop) { if ( @@ -265,16 +277,26 @@ const HoverResponder = { dispatchHoverStartEvents(event, context, props, state); state.isInHitSlop = false; } - } else if ( - state.isHovered && - context.isPositionWithinTouchHitTarget( - target.ownerDocument, - (nativeEvent: any).x, - (nativeEvent: any).y, - ) - ) { - dispatchHoverEndEvents(event, context, props, state); - state.isInHitSlop = true; + } else if (state.isHovered) { + if ( + context.isPositionWithinTouchHitTarget( + target.ownerDocument, + (nativeEvent: any).x, + (nativeEvent: any).y, + ) + ) { + dispatchHoverEndEvents(event, context, props, state); + state.isInHitSlop = true; + } else { + if (props.onHoverMove) { + const syntheticEvent = createHoverEvent( + 'hovermove', + event.target, + props.onHoverMove, + ); + context.dispatchEvent(syntheticEvent, {discrete: false}); + } + } } } break; diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 74d3fcc78f3de..1753301a40013 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -10,6 +10,7 @@ import type { ReactResponderEvent, ReactResponderContext, + ReactResponderDispatchEventOptions, } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; @@ -130,13 +131,17 @@ function dispatchEvent( state: PressState, name: PressEventType, listener: (e: Object) => void, + options?: ReactResponderDispatchEventOptions, ): void { const target = ((state.pressTarget: any): Element | Document); const pointerType = state.pointerType; const syntheticEvent = createPressEvent(name, target, listener, pointerType); - context.dispatchEvent(syntheticEvent, { - discrete: true, - }); + context.dispatchEvent( + syntheticEvent, + options || { + discrete: true, + }, + ); state.didDispatchEvent = true; } @@ -489,7 +494,9 @@ const PressResponder = { if (isPressWithinResponderRegion(nativeEvent, state)) { state.isPressWithinResponderRegion = true; if (props.onPressMove) { - dispatchEvent(context, state, 'pressmove', props.onPressMove); + dispatchEvent(context, state, 'pressmove', props.onPressMove, { + discrete: false, + }); } } else { state.isPressWithinResponderRegion = false; diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index 0d29036f988f3..151631eaa1f97 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -331,6 +331,36 @@ describe('Hover event responder', () => { }); }); + describe('onHoverMove', () => { + it('is called after "pointermove"', () => { + const onHoverMove = jest.fn(); + const ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.getBoundingClientRect = () => ({ + top: 50, + left: 50, + bottom: 500, + right: 500, + }); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent( + createPointerEvent('pointermove', {pointerType: 'mouse'}), + ); + ref.current.dispatchEvent(createPointerEvent('touchmove')); + ref.current.dispatchEvent(createPointerEvent('mousemove')); + expect(onHoverMove).toHaveBeenCalledTimes(1); + expect(onHoverMove).toHaveBeenCalledWith( + expect.objectContaining({type: 'hovermove'}), + ); + }); + }); + it('expect displayName to show up for event component', () => { expect(Hover.displayName).toBe('Hover'); });