From 7002a4937d9df8f024ea97349180446294448cbc Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 4 Apr 2019 13:32:30 -0700 Subject: [PATCH 1/2] Add delay props to Hover event module --- packages/react-events/src/Hover.js | 140 +++++++++++----- .../src/__tests__/Hover-test.internal.js | 149 +++++++++++++++++- 2 files changed, 248 insertions(+), 41 deletions(-) diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 050a50bda0c31..2addb3185e519 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -20,9 +20,12 @@ type HoverProps = { }; type HoverState = { + isActiveHovered: boolean, isHovered: boolean, isInHitSlop: boolean, isTouched: boolean, + hoverStartTimeout: null | TimeoutID, + hoverEndTimeout: null | TimeoutID, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; @@ -60,29 +63,67 @@ function createHoverEvent( }; } +function dispatchHoverChangeEvent( + event: ResponderEvent, + context: ResponderContext, + props: HoverProps, + state: HoverState, +): void { + const listener = () => { + props.onHoverChange(state.isActiveHovered); + }; + const syntheticEvent = createHoverEvent( + 'hoverchange', + event.target, + listener, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); +} + function dispatchHoverStartEvents( event: ResponderEvent, context: ResponderContext, props: HoverProps, + state: HoverState, ): void { const {nativeEvent, target} = event; if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } - if (props.onHoverStart) { - const syntheticEvent = createHoverEvent( - 'hoverstart', - target, - props.onHoverStart, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + state.isHovered = true; + + if (state.hoverEndTimeout !== null) { + clearTimeout(state.hoverEndTimeout); + state.hoverEndTimeout = null; } - if (props.onHoverChange) { - const listener = () => { - props.onHoverChange(true); - }; - const syntheticEvent = createHoverEvent('hoverchange', target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + const dispatch = () => { + state.isActiveHovered = true; + + if (props.onHoverStart) { + const syntheticEvent = createHoverEvent( + 'hoverstart', + target, + props.onHoverStart, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); + } + if (props.onHoverChange) { + dispatchHoverChangeEvent(event, context, props, state); + } + }; + + if (!state.isActiveHovered) { + const delay = calculateDelayMS(props.delayHoverStart, 0, 0); + if (delay > 0) { + state.hoverStartTimeout = context.setTimeout(() => { + state.hoverStartTimeout = null; + dispatch(); + }, delay); + } else { + dispatch(); + } } } @@ -90,35 +131,63 @@ function dispatchHoverEndEvents( event: ResponderEvent, context: ResponderContext, props: HoverProps, + state: HoverState, ) { const {nativeEvent, target} = event; if (context.isTargetWithinEventComponent((nativeEvent: any).relatedTarget)) { return; } - if (props.onHoverEnd) { - const syntheticEvent = createHoverEvent( - 'hoverend', - target, - props.onHoverEnd, - ); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + state.isHovered = false; + + if (state.hoverStartTimeout !== null) { + clearTimeout(state.hoverStartTimeout); + state.hoverStartTimeout = null; } - if (props.onHoverChange) { - const listener = () => { - props.onHoverChange(false); - }; - const syntheticEvent = createHoverEvent('hoverchange', target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + + const dispatch = () => { + state.isActiveHovered = false; + + if (props.onHoverEnd) { + const syntheticEvent = createHoverEvent( + 'hoverend', + target, + props.onHoverEnd, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); + } + if (props.onHoverChange) { + dispatchHoverChangeEvent(event, context, props, state); + } + }; + + if (state.isActiveHovered) { + const delay = calculateDelayMS(props.delayHoverEnd, 0, 0); + if (delay > 0) { + state.hoverEndTimeout = context.setTimeout(() => { + dispatch(); + }, delay); + } else { + dispatch(); + } } } +function calculateDelayMS(delay: ?number, min = 0, fallback = 0) { + const maybeNumber = delay == null ? null : delay; + return Math.max(min, maybeNumber != null ? maybeNumber : fallback); +} + const HoverResponder = { targetEventTypes, createInitialState() { return { + isActiveHovered: false, isHovered: false, isInHitSlop: false, isTouched: false, + hoverStartTimeout: null, + hoverEndTimeout: null, }; }, onEvent( @@ -156,23 +225,22 @@ const HoverResponder = { state.isInHitSlop = true; return; } - dispatchHoverStartEvents(event, context, props); - state.isHovered = true; + dispatchHoverStartEvents(event, context, props, state); } break; } case 'pointerout': case 'mouseout': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(event, context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); } state.isInHitSlop = false; state.isTouched = false; break; } + case 'pointermove': { - if (!state.isTouched) { + if (state.isHovered && !state.isTouched) { if (state.isInHitSlop) { if ( !context.isPositionWithinTouchHitTarget( @@ -180,8 +248,7 @@ const HoverResponder = { (nativeEvent: any).y, ) ) { - dispatchHoverStartEvents(event, context, props); - state.isHovered = true; + dispatchHoverStartEvents(event, context, props, state); state.isInHitSlop = false; } } else if ( @@ -191,17 +258,16 @@ const HoverResponder = { (nativeEvent: any).y, ) ) { - dispatchHoverEndEvents(event, context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); state.isInHitSlop = true; } } break; } + case 'pointercancel': { if (state.isHovered && !state.isTouched) { - dispatchHoverEndEvents(event, context, props); - state.isHovered = false; + dispatchHoverEndEvents(event, context, props, state); state.isTouched = false; } break; diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index bd449d8f2348b..c7cb373a1626b 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -84,8 +84,70 @@ describe('Hover event responder', () => { expect(onHoverStart).not.toBeCalled(); }); - // TODO: complete delayHoverStart tests - // describe('delayHoverStart', () => {}); + describe('delayHoverStart', () => { + it('can be configured', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(1999); + expect(onHoverStart).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + it('onHoverStart is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + it('onHoverStart is only called once per active hover', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(500); + expect(onHoverStart).toHaveBeenCalledTimes(1); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(10); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.runAllTimers(); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + it('onHoverStart is not called if "pointerout" is dispatched during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(1); + expect(onHoverStart).not.toBeCalled(); + }); + }); }); describe('onHoverChange', () => { @@ -183,8 +245,87 @@ describe('Hover event responder', () => { expect(onHoverEnd).not.toBeCalled(); }); - // TODO: complete delayHoverStart tests - // describe('delayHoverEnd', () => {}); + describe('delayHoverEnd', () => { + it('can be configured', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(1999); + expect(onHoverEnd).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('delayHoverEnd is called synchronously if delay is 0ms', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('onHoverEnd is only called once per active hover', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(100); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.runAllTimers(); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('onHoverEnd is not called if "pointerover" is dispatched during a delay', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.advanceTimersByTime(499); + ref.current.dispatchEvent(createPointerEvent('pointerover')); + jest.advanceTimersByTime(1); + expect(onHoverEnd).not.toBeCalled(); + }); + + it('onHoverEnd is not called if there was no active hover', () => { + const element = ( + +
+ + ); + ReactDOM.render(element, container); + + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + jest.runAllTimers(); + expect(onHoverEnd).not.toBeCalled(); + }); + }); }); it('expect displayName to show up for event component', () => { From 6c7069a75a7cb0568055524f22766b7679cebf97 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Fri, 5 Apr 2019 12:00:49 +0100 Subject: [PATCH 2/2] Fix bad rename --- packages/react-events/src/Swipe.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index ed211c939ef48..9d199d9fce69e 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -104,7 +104,7 @@ const SwipeResponder = { case 'mousedown': case 'pointerdown': { if (!state.isSwiping && !context.hasOwnership()) { - let obj = event; + let obj = nativeEvent; if (type === 'touchstart') { obj = (nativeEvent: any).targetTouches[0]; state.touchId = obj.identifier;