Skip to content

Commit 7002a49

Browse files
committed
Add delay props to Hover event module
1 parent b93a8a9 commit 7002a49

File tree

2 files changed

+248
-41
lines changed

2 files changed

+248
-41
lines changed

packages/react-events/src/Hover.js

Lines changed: 103 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,12 @@ type HoverProps = {
2020
};
2121

2222
type HoverState = {
23+
isActiveHovered: boolean,
2324
isHovered: boolean,
2425
isInHitSlop: boolean,
2526
isTouched: boolean,
27+
hoverStartTimeout: null | TimeoutID,
28+
hoverEndTimeout: null | TimeoutID,
2629
};
2730

2831
type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange';
@@ -60,65 +63,131 @@ function createHoverEvent(
6063
};
6164
}
6265

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+
6383
function dispatchHoverStartEvents(
6484
event: ResponderEvent,
6585
context: ResponderContext,
6686
props: HoverProps,
87+
state: HoverState,
6788
): void {
6889
const {nativeEvent, target} = event;
6990
if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) {
7091
return;
7192
}
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;
7999
}
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+
}
86127
}
87128
}
88129

89130
function dispatchHoverEndEvents(
90131
event: ResponderEvent,
91132
context: ResponderContext,
92133
props: HoverProps,
134+
state: HoverState,
93135
) {
94136
const {nativeEvent, target} = event;
95137
if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) {
96138
return;
97139
}
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;
105146
}
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+
}
112173
}
113174
}
114175

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+
115181
const HoverResponder = {
116182
targetEventTypes,
117183
createInitialState() {
118184
return {
185+
isActiveHovered: false,
119186
isHovered: false,
120187
isInHitSlop: false,
121188
isTouched: false,
189+
hoverStartTimeout: null,
190+
hoverEndTimeout: null,
122191
};
123192
},
124193
onEvent(
@@ -156,32 +225,30 @@ const HoverResponder = {
156225
state.isInHitSlop = true;
157226
return;
158227
}
159-
dispatchHoverStartEvents(event, context, props);
160-
state.isHovered = true;
228+
dispatchHoverStartEvents(event, context, props, state);
161229
}
162230
break;
163231
}
164232
case 'pointerout':
165233
case 'mouseout': {
166234
if (state.isHovered && !state.isTouched) {
167-
dispatchHoverEndEvents(event, context, props);
168-
state.isHovered = false;
235+
dispatchHoverEndEvents(event, context, props, state);
169236
}
170237
state.isInHitSlop = false;
171238
state.isTouched = false;
172239
break;
173240
}
241+
174242
case 'pointermove': {
175-
if (!state.isTouched) {
243+
if (state.isHovered && !state.isTouched) {
176244
if (state.isInHitSlop) {
177245
if (
178246
!context.isPositionWithinTouchHitTarget(
179247
(nativeEvent: any).x,
180248
(nativeEvent: any).y,
181249
)
182250
) {
183-
dispatchHoverStartEvents(event, context, props);
184-
state.isHovered = true;
251+
dispatchHoverStartEvents(event, context, props, state);
185252
state.isInHitSlop = false;
186253
}
187254
} else if (
@@ -191,17 +258,16 @@ const HoverResponder = {
191258
(nativeEvent: any).y,
192259
)
193260
) {
194-
dispatchHoverEndEvents(event, context, props);
195-
state.isHovered = false;
261+
dispatchHoverEndEvents(event, context, props, state);
196262
state.isInHitSlop = true;
197263
}
198264
}
199265
break;
200266
}
267+
201268
case 'pointercancel': {
202269
if (state.isHovered && !state.isTouched) {
203-
dispatchHoverEndEvents(event, context, props);
204-
state.isHovered = false;
270+
dispatchHoverEndEvents(event, context, props, state);
205271
state.isTouched = false;
206272
}
207273
break;

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

Lines changed: 145 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,70 @@ describe('Hover event responder', () => {
8484
expect(onHoverStart).not.toBeCalled();
8585
});
8686

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+
});
89151
});
90152

91153
describe('onHoverChange', () => {
@@ -183,8 +245,87 @@ describe('Hover event responder', () => {
183245
expect(onHoverEnd).not.toBeCalled();
184246
});
185247

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+
});
188329
});
189330

190331
it('expect displayName to show up for event component', () => {

0 commit comments

Comments
 (0)