Add delay props to Hover event module (#15325)
Nicolas Gallagher committed
Apr 5, 2019 at 04:23 UTC
958b6173fdcba503a4333d3a2e42c10781201f17
3 files changed
+249
-42
packages/react-events/src/Hover.js
+103
-37
@@ -20,9 +20,12 @@ type HoverProps = {
20
};
21
22
type HoverState = {
23
+ isActiveHovered: boolean,
24
isHovered: boolean,
25
isInHitSlop: boolean,
26
isTouched: boolean,
27
+ hoverStartTimeout: null | TimeoutID,
28
+ hoverEndTimeout: null | TimeoutID,
29
};
30
31
type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange';
@@ -60,29 +63,67 @@ function createHoverEvent(
63
};
64
}
65
66
+function dispatchHoverChangeEvent(
67
+ event: ResponderEvent,
68
+ context: ResponderContext,
69
+ props: HoverProps,
70
+ state: HoverState,
71
+): void {
72
+ const listener = () => {
73
+ props.onHoverChange(state.isActiveHovered);
74
+ };
75
+ const syntheticEvent = createHoverEvent(
76
+ 'hoverchange',
77
+ event.target,
78
+ listener,
79
+ );
80
+ context.dispatchEvent(syntheticEvent, {discrete: true});
81
+}
82
+
83
function dispatchHoverStartEvents(
84
event: ResponderEvent,
85
context: ResponderContext,
86
props: HoverProps,
87
+ state: HoverState,
88
): void {
89
const {nativeEvent, target} = event;
90
if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) {
91
return;
92
}
72
- if (props.onHoverStart) {
73
- const syntheticEvent = createHoverEvent(
74
- 'hoverstart',
75
- target,
76
- props.onHoverStart,
77
- );
78
- context.dispatchEvent(syntheticEvent, {discrete: true});
93
+
94
+ state.isHovered = true;
95
+
96
+ if (state.hoverEndTimeout !== null) {
97
+ clearTimeout(state.hoverEndTimeout);
98
+ state.hoverEndTimeout = null;
99
}
80
- if (props.onHoverChange) {
81
- const listener = () => {
82
- props.onHoverChange(true);
83
- };
84
- const syntheticEvent = createHoverEvent('hoverchange', target, listener);
85
- context.dispatchEvent(syntheticEvent, {discrete: true});
100
+
101
+ const dispatch = () => {
102
+ state.isActiveHovered = true;
103
+
104
+ if (props.onHoverStart) {
105
+ const syntheticEvent = createHoverEvent(
106
+ 'hoverstart',
107
+ target,
108
+ props.onHoverStart,
109
+ );
110
+ context.dispatchEvent(syntheticEvent, {discrete: true});
111
+ }
112
+ if (props.onHoverChange) {
113
+ dispatchHoverChangeEvent(event, context, props, state);
114
+ }
115
+ };
116
+
117
+ if (!state.isActiveHovered) {
118
+ const delay = calculateDelayMS(props.delayHoverStart, 0, 0);
119
+ if (delay > 0) {
120
+ state.hoverStartTimeout = context.setTimeout(() => {
121
+ state.hoverStartTimeout = null;
122
+ dispatch();
123
+ }, delay);
124
+ } else {
125
+ dispatch();
126
+ }
127
}
128
}
129
@@ -90,35 +131,63 @@ function dispatchHoverEndEvents(
131
event: ResponderEvent,
132
context: ResponderContext,
133
props: HoverProps,
134
+ state: HoverState,
135
) {
136
const {nativeEvent, target} = event;
137
if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) {
138
return;
139
}
98
- if (props.onHoverEnd) {
99
- const syntheticEvent = createHoverEvent(
100
- 'hoverend',
101
- target,
102
- props.onHoverEnd,
103
- );
104
- context.dispatchEvent(syntheticEvent, {discrete: true});
140
+
141
+ state.isHovered = false;
142
+
143
+ if (state.hoverStartTimeout !== null) {
144
+ clearTimeout(state.hoverStartTimeout);
145
+ state.hoverStartTimeout = null;
146
}
106
- if (props.onHoverChange) {
107
- const listener = () => {
108
- props.onHoverChange(false);
109
- };
110
- const syntheticEvent = createHoverEvent('hoverchange', target, listener);
111
- context.dispatchEvent(syntheticEvent, {discrete: true});
147
+
148
+ const dispatch = () => {
149
+ state.isActiveHovered = false;
150
+
151
+ if (props.onHoverEnd) {
152
+ const syntheticEvent = createHoverEvent(
153
+ 'hoverend',
154
+ target,
155
+ props.onHoverEnd,
156
+ );
157
+ context.dispatchEvent(syntheticEvent, {discrete: true});
158
+ }
159
+ if (props.onHoverChange) {
160
+ dispatchHoverChangeEvent(event, context, props, state);
161
+ }
162
+ };
163
+
164
+ if (state.isActiveHovered) {
165
+ const delay = calculateDelayMS(props.delayHoverEnd, 0, 0);
166
+ if (delay > 0) {
167
+ state.hoverEndTimeout = context.setTimeout(() => {
168
+ dispatch();
169
+ }, delay);
170
+ } else {
171
+ dispatch();
172
+ }
173
}
174
}
175
176
+function calculateDelayMS(delay: ?number, min = 0, fallback = 0) {
177
+ const maybeNumber = delay == null ? null : delay;
178
+ return Math.max(min, maybeNumber != null ? maybeNumber : fallback);
179
+}
180
+
181
const HoverResponder = {
182
targetEventTypes,
183
createInitialState() {
184
return {
185
+ isActiveHovered: false,
186
isHovered: false,
187
isInHitSlop: false,
188
isTouched: false,
189
+ hoverStartTimeout: null,
190
+ hoverEndTimeout: null,
191
};
192
},
193
onEvent(
@@ -156,23 +225,22 @@ const HoverResponder = {
225
state.isInHitSlop = true;
226
return;
227
}
159
- dispatchHoverStartEvents(event, context, props);
160
- state.isHovered = true;
228
+ dispatchHoverStartEvents(event, context, props, state);
229
}
230
break;
231
}
232
case 'pointerout':
233
case 'mouseout': {
234
if (state.isHovered && !state.isTouched) {
167
- dispatchHoverEndEvents(event, context, props);
168
- state.isHovered = false;
235
+ dispatchHoverEndEvents(event, context, props, state);
236
}
237
state.isInHitSlop = false;
238
state.isTouched = false;
239
break;
240
}
241
+
242
case 'pointermove': {
175
- if (!state.isTouched) {
243
+ if (state.isHovered && !state.isTouched) {
244
if (state.isInHitSlop) {
245
if (
246
!context.isPositionWithinTouchHitTarget(
@@ -180,8 +248,7 @@ const HoverResponder = {
248
(nativeEvent: any).y,
249
)
250
) {
183
- dispatchHoverStartEvents(event, context, props);
184
- state.isHovered = true;
251
+ dispatchHoverStartEvents(event, context, props, state);
252
state.isInHitSlop = false;
253
}
254
} else if (
@@ -191,17 +258,16 @@ const HoverResponder = {
258
(nativeEvent: any).y,
259
)
260
) {
194
- dispatchHoverEndEvents(event, context, props);
195
- state.isHovered = false;
261
+ dispatchHoverEndEvents(event, context, props, state);
262
state.isInHitSlop = true;
263
}
264
}
265
break;
266
}
267
+
268
case 'pointercancel': {
269
if (state.isHovered && !state.isTouched) {
203
- dispatchHoverEndEvents(event, context, props);
204
- state.isHovered = false;
270
+ dispatchHoverEndEvents(event, context, props, state);
271
state.isTouched = false;
272
}
273
break;
packages/react-events/src/Swipe.js
+1
-1
@@ -104,7 +104,7 @@ const SwipeResponder = {
104
case 'mousedown':
105
case 'pointerdown': {
106
if (!state.isSwiping && !context.hasOwnership()) {
107
- let obj = event;
107
+ let obj = nativeEvent;
108
if (type === 'touchstart') {
109
obj = (nativeEvent: any).targetTouches[0];
110
state.touchId = obj.identifier;
packages/react-events/src/__tests__/Hover-test.internal.js
+145
-4
@@ -84,8 +84,70 @@ describe('Hover event responder', () => {
84
expect(onHoverStart).not.toBeCalled();
85
});
86
87
- // TODO: complete delayHoverStart tests
88
- // describe('delayHoverStart', () => {});
87
+ describe('delayHoverStart', () => {
88
+ it('can be configured', () => {
89
+ const element = (
90
+ <Hover delayHoverStart={2000} onHoverStart={onHoverStart}>
91
+ <div ref={ref} />
92
+ </Hover>
93
+ );
94
+ ReactDOM.render(element, container);
95
+
96
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
97
+ jest.advanceTimersByTime(1999);
98
+ expect(onHoverStart).not.toBeCalled();
99
+ jest.advanceTimersByTime(1);
100
+ expect(onHoverStart).toHaveBeenCalledTimes(1);
101
+ });
102
+
103
+ it('onHoverStart is called synchronously if delay is 0ms', () => {
104
+ const element = (
105
+ <Hover delayHoverStart={0} onHoverStart={onHoverStart}>
106
+ <div ref={ref} />
107
+ </Hover>
108
+ );
109
+ ReactDOM.render(element, container);
110
+
111
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
112
+ expect(onHoverStart).toHaveBeenCalledTimes(1);
113
+ });
114
+
115
+ it('onHoverStart is only called once per active hover', () => {
116
+ const element = (
117
+ <Hover
118
+ delayHoverStart={500}
119
+ delayHoverEnd={100}
120
+ onHoverStart={onHoverStart}>
121
+ <div ref={ref} />
122
+ </Hover>
123
+ );
124
+ ReactDOM.render(element, container);
125
+
126
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
127
+ jest.advanceTimersByTime(500);
128
+ expect(onHoverStart).toHaveBeenCalledTimes(1);
129
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
130
+ jest.advanceTimersByTime(10);
131
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
132
+ jest.runAllTimers();
133
+ expect(onHoverStart).toHaveBeenCalledTimes(1);
134
+ });
135
+
136
+ it('onHoverStart is not called if "pointerout" is dispatched during a delay', () => {
137
+ const element = (
138
+ <Hover delayHoverStart={500} onHoverStart={onHoverStart}>
139
+ <div ref={ref} />
140
+ </Hover>
141
+ );
142
+ ReactDOM.render(element, container);
143
+
144
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
145
+ jest.advanceTimersByTime(499);
146
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
147
+ jest.advanceTimersByTime(1);
148
+ expect(onHoverStart).not.toBeCalled();
149
+ });
150
+ });
151
});
152
153
describe('onHoverChange', () => {
@@ -183,8 +245,87 @@ describe('Hover event responder', () => {
245
expect(onHoverEnd).not.toBeCalled();
246
});
247
186
- // TODO: complete delayHoverStart tests
187
- // describe('delayHoverEnd', () => {});
248
+ describe('delayHoverEnd', () => {
249
+ it('can be configured', () => {
250
+ const element = (
251
+ <Hover delayHoverEnd={2000} onHoverEnd={onHoverEnd}>
252
+ <div ref={ref} />
253
+ </Hover>
254
+ );
255
+ ReactDOM.render(element, container);
256
+
257
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
258
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
259
+ jest.advanceTimersByTime(1999);
260
+ expect(onHoverEnd).not.toBeCalled();
261
+ jest.advanceTimersByTime(1);
262
+ expect(onHoverEnd).toHaveBeenCalledTimes(1);
263
+ });
264
+
265
+ it('delayHoverEnd is called synchronously if delay is 0ms', () => {
266
+ const element = (
267
+ <Hover delayHoverEnd={0} onHoverEnd={onHoverEnd}>
268
+ <div ref={ref} />
269
+ </Hover>
270
+ );
271
+ ReactDOM.render(element, container);
272
+
273
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
274
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
275
+ expect(onHoverEnd).toHaveBeenCalledTimes(1);
276
+ });
277
+
278
+ it('onHoverEnd is only called once per active hover', () => {
279
+ const element = (
280
+ <Hover delayHoverEnd={500} onHoverEnd={onHoverEnd}>
281
+ <div ref={ref} />
282
+ </Hover>
283
+ );
284
+ ReactDOM.render(element, container);
285
+
286
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
287
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
288
+ jest.advanceTimersByTime(499);
289
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
290
+ jest.advanceTimersByTime(100);
291
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
292
+ jest.runAllTimers();
293
+ expect(onHoverEnd).toHaveBeenCalledTimes(1);
294
+ });
295
+
296
+ it('onHoverEnd is not called if "pointerover" is dispatched during a delay', () => {
297
+ const element = (
298
+ <Hover delayHoverEnd={500} onHoverEnd={onHoverEnd}>
299
+ <div ref={ref} />
300
+ </Hover>
301
+ );
302
+ ReactDOM.render(element, container);
303
+
304
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
305
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
306
+ jest.advanceTimersByTime(499);
307
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
308
+ jest.advanceTimersByTime(1);
309
+ expect(onHoverEnd).not.toBeCalled();
310
+ });
311
+
312
+ it('onHoverEnd is not called if there was no active hover', () => {
313
+ const element = (
314
+ <Hover
315
+ delayHoverStart={500}
316
+ delayHoverEnd={100}
317
+ onHoverEnd={onHoverEnd}>
318
+ <div ref={ref} />
319
+ </Hover>
320
+ );
321
+ ReactDOM.render(element, container);
322
+
323
+ ref.current.dispatchEvent(createPointerEvent('pointerover'));
324
+ ref.current.dispatchEvent(createPointerEvent('pointerout'));
325
+ jest.runAllTimers();
326
+ expect(onHoverEnd).not.toBeCalled();
327
+ });
328
+ });
329
});
330
331
it('expect displayName to show up for event component', () => {