[react-interactions] Fix Hover issues with portals (#17765)
Dominic Gannaway committed
Jan 7, 2020 at 19:45 UTC
5e21157d86a4f589891e90f4103b3a411af889a3
2 files changed
+111
-45
packages/react-interactions/events/src/dom/Hover.js
+85
-45
@@ -112,14 +112,14 @@ function dispatchHoverStartEvents(
112
context: ReactDOMResponderContext,
113
props: HoverProps,
114
state: HoverState,
115
-): void {
115
+): boolean {
116
const target = state.hoverTarget;
117
if (event !== null) {
118
const {nativeEvent} = event;
119
if (
120
context.isTargetWithinResponderScope((nativeEvent: any).relatedTarget)
121
) {
122
- return;
122
+ return false;
123
}
124
}
125
@@ -139,6 +139,7 @@ function dispatchHoverStartEvents(
139
}
140
dispatchHoverChangeEvent(event, context, props, state);
141
}
142
+ return true;
143
}
144
145
function dispatchHoverMoveEvent(event, context, props, state) {
@@ -160,14 +161,14 @@ function dispatchHoverEndEvents(
161
context: ReactDOMResponderContext,
162
props: HoverProps,
163
state: HoverState,
163
-) {
164
+): boolean {
165
const target = state.hoverTarget;
166
if (event !== null) {
167
const {nativeEvent} = event;
168
if (
169
context.isTargetWithinResponderScope((nativeEvent: any).relatedTarget)
170
) {
170
- return;
171
+ return false;
172
}
173
}
174
@@ -189,6 +190,7 @@ function dispatchHoverEndEvents(
190
state.hoverTarget = null;
191
state.isTouched = false;
192
}
193
+ return true;
194
}
195
196
function unmountResponder(
@@ -201,13 +203,10 @@ function unmountResponder(
203
}
204
}
205
206
+const rootPointerEventTypes = ['pointerout', 'pointermove', 'pointercancel'];
207
+
208
const hoverResponderImpl = {
205
- targetEventTypes: [
206
- 'pointerover',
207
- 'pointermove',
208
- 'pointerout',
209
- 'pointercancel',
210
- ],
209
+ targetEventTypes: ['pointerover'],
210
getInitialState() {
211
return {
212
isActiveHovered: false,
@@ -216,6 +215,38 @@ const hoverResponderImpl = {
215
},
216
allowMultipleHostChildren: false,
217
allowEventHooks: true,
218
+ onRootEvent(
219
+ event: ReactDOMResponderEvent,
220
+ context: ReactDOMResponderContext,
221
+ props: HoverProps,
222
+ state: HoverState,
223
+ ): void {
224
+ const {type} = event;
225
+
226
+ switch (type) {
227
+ // MOVE
228
+ case 'pointermove': {
229
+ if (state.isHovered && state.hoverTarget !== null) {
230
+ dispatchHoverMoveEvent(event, context, props, state);
231
+ }
232
+ break;
233
+ }
234
+
235
+ // END
236
+ case 'pointercancel':
237
+ case 'pointerout': {
238
+ if (state.isHovered) {
239
+ if (
240
+ dispatchHoverEndEvents(event, context, props, state) ||
241
+ type === 'pointercancel'
242
+ ) {
243
+ context.removeRootEventTypes(rootPointerEventTypes);
244
+ }
245
+ }
246
+ break;
247
+ }
248
+ }
249
+ },
250
onEvent(
251
event: ReactDOMResponderEvent,
252
context: ReactDOMResponderContext,
@@ -226,6 +257,7 @@ const hoverResponderImpl = {
257
258
if (props.disabled) {
259
if (state.isHovered) {
260
+ context.removeRootEventTypes(rootPointerEventTypes);
261
dispatchHoverEndEvents(event, context, props, state);
262
}
263
return;
@@ -236,24 +268,9 @@ const hoverResponderImpl = {
268
case 'pointerover': {
269
if (!state.isHovered && pointerType !== 'touch') {
270
state.hoverTarget = context.getResponderNode();
239
- dispatchHoverStartEvents(event, context, props, state);
240
- }
241
- break;
242
- }
243
-
244
- // MOVE
245
- case 'pointermove': {
246
- if (state.isHovered && state.hoverTarget !== null) {
247
- dispatchHoverMoveEvent(event, context, props, state);
248
- }
249
- break;
250
- }
251
-
252
- // END
253
- case 'pointerout':
254
- case 'pointercancel': {
255
- if (state.isHovered) {
256
- dispatchHoverEndEvents(event, context, props, state);
271
+ if (dispatchHoverStartEvents(event, context, props, state)) {
272
+ context.addRootEventTypes(rootPointerEventTypes);
273
+ }
274
}
275
break;
276
}
@@ -262,8 +279,10 @@ const hoverResponderImpl = {
279
onUnmount: unmountResponder,
280
};
281
282
+const rootMouseEventTypes = ['mousemove', 'mouseout'];
283
+
284
const hoverResponderFallbackImpl = {
266
- targetEventTypes: ['mouseover', 'mousemove', 'mouseout', 'touchstart'],
285
+ targetEventTypes: ['mouseover', 'mousemove', 'touchstart'],
286
getInitialState() {
287
return {
288
isActiveHovered: false,
@@ -274,6 +293,38 @@ const hoverResponderFallbackImpl = {
293
},
294
allowMultipleHostChildren: false,
295
allowEventHooks: true,
296
+ onRootEvent(
297
+ event: ReactDOMResponderEvent,
298
+ context: ReactDOMResponderContext,
299
+ props: HoverProps,
300
+ state: HoverState,
301
+ ): void {
302
+ const {type} = event;
303
+
304
+ switch (type) {
305
+ // MOVE
306
+ case 'mousemove': {
307
+ if (
308
+ state.isHovered &&
309
+ state.hoverTarget !== null &&
310
+ !state.ignoreEmulatedMouseEvents
311
+ ) {
312
+ dispatchHoverMoveEvent(event, context, props, state);
313
+ }
314
+ break;
315
+ }
316
+
317
+ // END
318
+ case 'mouseout': {
319
+ if (state.isHovered) {
320
+ if (dispatchHoverEndEvents(event, context, props, state)) {
321
+ context.removeRootEventTypes(rootMouseEventTypes);
322
+ }
323
+ }
324
+ break;
325
+ }
326
+ }
327
+ },
328
onEvent(
329
event: ReactDOMResponderEvent,
330
context: ReactDOMResponderContext,
@@ -284,6 +335,7 @@ const hoverResponderFallbackImpl = {
335
336
if (props.disabled) {
337
if (state.isHovered) {
338
+ context.removeRootEventTypes(rootMouseEventTypes);
339
dispatchHoverEndEvents(event, context, props, state);
340
state.ignoreEmulatedMouseEvents = false;
341
}
@@ -296,34 +348,22 @@ const hoverResponderFallbackImpl = {
348
case 'mouseover': {
349
if (!state.isHovered && !state.ignoreEmulatedMouseEvents) {
350
state.hoverTarget = context.getResponderNode();
299
- dispatchHoverStartEvents(event, context, props, state);
351
+ if (dispatchHoverStartEvents(event, context, props, state)) {
352
+ context.addRootEventTypes(rootMouseEventTypes);
353
+ }
354
}
355
break;
356
}
357
358
// MOVE
359
case 'mousemove': {
306
- if (
307
- state.isHovered &&
308
- state.hoverTarget !== null &&
309
- !state.ignoreEmulatedMouseEvents
310
- ) {
311
- dispatchHoverMoveEvent(event, context, props, state);
312
- } else if (!state.isHovered && type === 'mousemove') {
360
+ if (!state.isHovered && type === 'mousemove') {
361
state.ignoreEmulatedMouseEvents = false;
362
state.isTouched = false;
363
}
364
break;
365
}
366
319
- // END
320
- case 'mouseout': {
321
- if (state.isHovered) {
322
- dispatchHoverEndEvents(event, context, props, state);
323
- }
324
- break;
325
- }
326
-
367
case 'touchstart': {
368
if (!state.isHovered) {
369
state.isTouched = true;
packages/react-interactions/events/src/dom/__tests__/Hover-test.internal.js
+26
@@ -193,6 +193,32 @@ describe.each(table)('Hover responder', hasPointerEvents => {
193
target.pointerup({pointerType: 'touch'});
194
expect(onHoverEnd).not.toBeCalled();
195
});
196
+
197
+ it('should correctly work with React Portals', () => {
198
+ const portalNode = document.createElement('div');
199
+ const divRef = React.createRef();
200
+ const spanRef = React.createRef();
201
+
202
+ function Test() {
203
+ const listener = useHover({
204
+ onHoverEnd,
205
+ });
206
+ return (
207
+ <div ref={divRef} DEPRECATED_flareListeners={listener}>
208
+ {ReactDOM.createPortal(<span ref={spanRef} />, portalNode)}
209
+ </div>
210
+ );
211
+ }
212
+ ReactDOM.render(<Test />, container);
213
+ const div = createEventTarget(divRef.current);
214
+ div.pointerenter();
215
+ const span = createEventTarget(spanRef.current);
216
+ span.pointerexit();
217
+ expect(onHoverEnd).not.toBeCalled();
218
+ const body = createEventTarget(document.body);
219
+ body.pointerexit();
220
+ expect(onHoverEnd).toBeCalled();
221
+ });
222
});
223
224
describe('onHoverMove', () => {