Skip to content

Commit 4e59d4f

Browse files
authored
React events: add onHoverMove support (#15388)
1 parent cdfb06e commit 4e59d4f

File tree

4 files changed

+111
-22
lines changed

4 files changed

+111
-22
lines changed

packages/react-events/README.md

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ const TextField = (props) => (
2828

2929
```js
3030
// Types
31-
type FocusEvent = {}
31+
type FocusEvent = {
32+
type: 'blur' | 'focus' | 'focuschange'
33+
}
3234
```
3335

3436
### disabled: boolean
@@ -76,7 +78,10 @@ const Link = (props) => (
7678

7779
```js
7880
// Types
79-
type HoverEvent = {}
81+
type HoverEvent = {
82+
pointerType: 'mouse',
83+
type: 'hoverstart' | 'hoverend' | 'hovermove' | 'hoverchange'
84+
}
8085
```
8186

8287
### delayHoverEnd: number
@@ -103,12 +108,25 @@ Called when the element changes hover state (i.e., after `onHoverStart` and
103108
Called once the element is no longer hovered. It will be cancelled if the
104109
pointer leaves the element before the `delayHoverStart` threshold is exceeded.
105110

111+
### onHoverMove: (e: HoverEvent) => void
112+
113+
Called when the pointer moves within the hit bounds of the element. `onHoverMove` is
114+
called immediately and doesn't wait for delayed `onHoverStart`.
115+
106116
### onHoverStart: (e: HoverEvent) => void
107117

108118
Called once the element is hovered. It will not be called if the pointer leaves
109119
the element before the `delayHoverStart` threshold is exceeded. And it will not
110120
be called more than once before `onHoverEnd` is called.
111121

122+
### preventDefault: boolean = true
123+
124+
Whether to `preventDefault()` native events.
125+
126+
### stopPropagation: boolean = true
127+
128+
Whether to `stopPropagation()` native events.
129+
112130

113131
## Press
114132

@@ -145,7 +163,10 @@ const Button = (props) => (
145163

146164
```js
147165
// Types
148-
type PressEvent = {}
166+
type PressEvent = {
167+
pointerType: 'mouse' | 'touch' | 'pen' | 'keyboard',
168+
type: 'press' | 'pressstart' | 'pressend' | 'presschange' | 'pressmove' | 'longpress' | 'longpresschange'
169+
}
149170

150171
type PressOffset = {
151172
top: number,
@@ -210,8 +231,9 @@ called during a press.
210231

211232
### onPressMove: (e: PressEvent) => void
212233

213-
Called when an active press moves within the hit bounds of the element. Never
214-
called for keyboard-initiated press events.
234+
Called when a press moves within the hit bounds of the element. `onPressMove` is
235+
called immediately and doesn't wait for delayed `onPressStart`. Never called for
236+
keyboard-initiated press events.
215237

216238
### onPressStart: (e: PressEvent) => void
217239

@@ -225,3 +247,11 @@ Defines how far the pointer (while held down) may move outside the bounds of the
225247
element before it is deactivated. Once deactivated, the pointer (still held
226248
down) can be moved back within the bounds of the element to reactivate it.
227249
Ensure you pass in a constant to reduce memory allocations.
250+
251+
### preventDefault: boolean = true
252+
253+
Whether to `preventDefault()` native events.
254+
255+
### stopPropagation: boolean = true
256+
257+
Whether to `stopPropagation()` native events.

packages/react-events/src/Hover.js

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type HoverProps = {
1919
delayHoverStart: number,
2020
onHoverChange: boolean => void,
2121
onHoverEnd: (e: HoverEvent) => void,
22+
onHoverMove: (e: HoverEvent) => void,
2223
onHoverStart: (e: HoverEvent) => void,
2324
};
2425

@@ -29,9 +30,10 @@ type HoverState = {
2930
isTouched: boolean,
3031
hoverStartTimeout: null | Symbol,
3132
hoverEndTimeout: null | Symbol,
33+
skipMouseAfterPointer: boolean,
3234
};
3335

34-
type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange';
36+
type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove';
3537

3638
type HoverEvent = {|
3739
listener: HoverEvent => void,
@@ -51,7 +53,7 @@ const targetEventTypes = [
5153

5254
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
5355
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
54-
targetEventTypes.push('touchstart', 'mouseover', 'mouseout');
56+
targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout');
5557
}
5658

5759
function createHoverEvent(
@@ -200,6 +202,7 @@ const HoverResponder = {
200202
isTouched: false,
201203
hoverStartTimeout: null,
202204
hoverEndTimeout: null,
205+
skipMouseAfterPointer: false,
203206
};
204207
},
205208
onEvent(
@@ -228,6 +231,9 @@ const HoverResponder = {
228231
state.isTouched = true;
229232
return;
230233
}
234+
if (type === 'pointerover') {
235+
state.skipMouseAfterPointer = true;
236+
}
231237
if (
232238
context.isPositionWithinTouchHitTarget(
233239
target.ownerDocument,
@@ -249,10 +255,16 @@ const HoverResponder = {
249255
}
250256
state.isInHitSlop = false;
251257
state.isTouched = false;
258+
state.skipMouseAfterPointer = false;
252259
break;
253260
}
254261

255-
case 'pointermove': {
262+
case 'pointermove':
263+
case 'mousemove': {
264+
if (type === 'mousemove' && state.skipMouseAfterPointer === true) {
265+
return;
266+
}
267+
256268
if (state.isHovered && !state.isTouched) {
257269
if (state.isInHitSlop) {
258270
if (
@@ -265,16 +277,26 @@ const HoverResponder = {
265277
dispatchHoverStartEvents(event, context, props, state);
266278
state.isInHitSlop = false;
267279
}
268-
} else if (
269-
state.isHovered &&
270-
context.isPositionWithinTouchHitTarget(
271-
target.ownerDocument,
272-
(nativeEvent: any).x,
273-
(nativeEvent: any).y,
274-
)
275-
) {
276-
dispatchHoverEndEvents(event, context, props, state);
277-
state.isInHitSlop = true;
280+
} else if (state.isHovered) {
281+
if (
282+
context.isPositionWithinTouchHitTarget(
283+
target.ownerDocument,
284+
(nativeEvent: any).x,
285+
(nativeEvent: any).y,
286+
)
287+
) {
288+
dispatchHoverEndEvents(event, context, props, state);
289+
state.isInHitSlop = true;
290+
} else {
291+
if (props.onHoverMove) {
292+
const syntheticEvent = createHoverEvent(
293+
'hovermove',
294+
event.target,
295+
props.onHoverMove,
296+
);
297+
context.dispatchEvent(syntheticEvent, {discrete: false});
298+
}
299+
}
278300
}
279301
}
280302
break;

packages/react-events/src/Press.js

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {
1111
ReactResponderEvent,
1212
ReactResponderContext,
13+
ReactResponderDispatchEventOptions,
1314
} from 'shared/ReactTypes';
1415
import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols';
1516

@@ -130,13 +131,17 @@ function dispatchEvent(
130131
state: PressState,
131132
name: PressEventType,
132133
listener: (e: Object) => void,
134+
options?: ReactResponderDispatchEventOptions,
133135
): void {
134136
const target = ((state.pressTarget: any): Element | Document);
135137
const pointerType = state.pointerType;
136138
const syntheticEvent = createPressEvent(name, target, listener, pointerType);
137-
context.dispatchEvent(syntheticEvent, {
138-
discrete: true,
139-
});
139+
context.dispatchEvent(
140+
syntheticEvent,
141+
options || {
142+
discrete: true,
143+
},
144+
);
140145
state.didDispatchEvent = true;
141146
}
142147

@@ -489,7 +494,9 @@ const PressResponder = {
489494
if (isPressWithinResponderRegion(nativeEvent, state)) {
490495
state.isPressWithinResponderRegion = true;
491496
if (props.onPressMove) {
492-
dispatchEvent(context, state, 'pressmove', props.onPressMove);
497+
dispatchEvent(context, state, 'pressmove', props.onPressMove, {
498+
discrete: false,
499+
});
493500
}
494501
} else {
495502
state.isPressWithinResponderRegion = false;

packages/react-events/src/__tests__/Hover-test.internal.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,36 @@ describe('Hover event responder', () => {
331331
});
332332
});
333333

334+
describe('onHoverMove', () => {
335+
it('is called after "pointermove"', () => {
336+
const onHoverMove = jest.fn();
337+
const ref = React.createRef();
338+
const element = (
339+
<Hover onHoverMove={onHoverMove}>
340+
<div ref={ref} />
341+
</Hover>
342+
);
343+
ReactDOM.render(element, container);
344+
345+
ref.current.getBoundingClientRect = () => ({
346+
top: 50,
347+
left: 50,
348+
bottom: 500,
349+
right: 500,
350+
});
351+
ref.current.dispatchEvent(createPointerEvent('pointerover'));
352+
ref.current.dispatchEvent(
353+
createPointerEvent('pointermove', {pointerType: 'mouse'}),
354+
);
355+
ref.current.dispatchEvent(createPointerEvent('touchmove'));
356+
ref.current.dispatchEvent(createPointerEvent('mousemove'));
357+
expect(onHoverMove).toHaveBeenCalledTimes(1);
358+
expect(onHoverMove).toHaveBeenCalledWith(
359+
expect.objectContaining({type: 'hovermove'}),
360+
);
361+
});
362+
});
363+
334364
it('expect displayName to show up for event component', () => {
335365
expect(Hover.displayName).toBe('Hover');
336366
});

0 commit comments

Comments
 (0)