From 937d262f557fd3332576be0da60adf5f94c545d9 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Thu, 4 Apr 2019 08:55:35 -0700 Subject: [PATCH] React events: keyboard press, types, tests (#15314) * Add HoverProps type * Add more Hover event module tests * Add more Press event module tests * Change default longPress delay from 1000 to 500 * Rename dispatchPressEvent -> dispatchEvent * Consolidate state updates in Press event module * Add keyboard support for Press events * Add FocusProps type and unit tests --- packages/react-events/src/Focus.js | 33 +- packages/react-events/src/Hover.js | 65 ++-- packages/react-events/src/Press.js | 303 ++++++++++-------- .../src/__tests__/Focus-test.internal.js | 111 +++++++ .../src/__tests__/Hover-test.internal.js | 199 ++++++++---- .../src/__tests__/Press-test.internal.js | 114 ++++++- 6 files changed, 584 insertions(+), 241 deletions(-) create mode 100644 packages/react-events/src/__tests__/Focus-test.internal.js diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index e56379f8871dd..0b26bde9757bf 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -10,10 +10,12 @@ import type {EventResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -const targetEventTypes = [ - {name: 'focus', passive: true, capture: true}, - {name: 'blur', passive: true, capture: true}, -]; +type FocusProps = { + disabled: boolean, + onBlur: (e: FocusEvent) => void, + onFocus: (e: FocusEvent) => void, + onFocusChange: boolean => void, +}; type FocusState = { isFocused: boolean, @@ -27,6 +29,11 @@ type FocusEvent = {| type: FocusEventType, |}; +const targetEventTypes = [ + {name: 'focus', passive: true, capture: true}, + {name: 'blur', passive: true, capture: true}, +]; + function createFocusEvent( type: FocusEventType, target: Element | Document, @@ -39,7 +46,10 @@ function createFocusEvent( }; } -function dispatchFocusInEvents(context: EventResponderContext, props: Object) { +function dispatchFocusInEvents( + context: EventResponderContext, + props: FocusProps, +) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; @@ -53,19 +63,22 @@ function dispatchFocusInEvents(context: EventResponderContext, props: Object) { context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { - const focusChangeEventListener = () => { + const listener = () => { props.onFocusChange(true); }; const syntheticEvent = createFocusEvent( 'focuschange', eventTarget, - focusChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } } -function dispatchFocusOutEvents(context: EventResponderContext, props: Object) { +function dispatchFocusOutEvents( + context: EventResponderContext, + props: FocusProps, +) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; @@ -75,13 +88,13 @@ function dispatchFocusOutEvents(context: EventResponderContext, props: Object) { context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { - const focusChangeEventListener = () => { + const listener = () => { props.onFocusChange(false); }; const syntheticEvent = createFocusEvent( 'focuschange', eventTarget, - focusChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 226430b838d09..ff42aac232e79 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -10,12 +10,14 @@ import type {EventResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -const targetEventTypes = [ - 'pointerover', - 'pointermove', - 'pointerout', - 'pointercancel', -]; +type HoverProps = { + disabled: boolean, + delayHoverEnd: number, + delayHoverStart: number, + onHoverChange: boolean => void, + onHoverEnd: (e: HoverEvent) => void, + onHoverStart: (e: HoverEvent) => void, +}; type HoverState = { isHovered: boolean, @@ -31,6 +33,21 @@ type HoverEvent = {| type: HoverEventType, |}; +// const DEFAULT_HOVER_END_DELAY_MS = 0; +// const DEFAULT_HOVER_START_DELAY_MS = 0; + +const targetEventTypes = [ + 'pointerover', + 'pointermove', + 'pointerout', + 'pointercancel', +]; + +// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events. +if (typeof window !== 'undefined' && window.PointerEvent === undefined) { + targetEventTypes.push('touchstart', 'mouseover', 'mouseout'); +} + function createHoverEvent( type: HoverEventType, target: Element | Document, @@ -43,16 +60,9 @@ function createHoverEvent( }; } -// In the case we don't have PointerEvents (Safari), we listen to touch events -// too -if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'mouseover', 'mouseout'); -} - function dispatchHoverStartEvents( context: EventResponderContext, - props: Object, - state: HoverState, + props: HoverProps, ): void { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { @@ -67,19 +77,22 @@ function dispatchHoverStartEvents( context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onHoverChange) { - const hoverChangeEventListener = () => { + const listener = () => { props.onHoverChange(true); }; const syntheticEvent = createHoverEvent( 'hoverchange', eventTarget, - hoverChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } } -function dispatchHoverEndEvents(context: EventResponderContext, props: Object) { +function dispatchHoverEndEvents( + context: EventResponderContext, + props: HoverProps, +) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; @@ -93,13 +106,13 @@ function dispatchHoverEndEvents(context: EventResponderContext, props: Object) { context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onHoverChange) { - const hoverChangeEventListener = () => { + const listener = () => { props.onHoverChange(false); }; const syntheticEvent = createHoverEvent( 'hoverchange', eventTarget, - hoverChangeEventListener, + listener, ); context.dispatchEvent(syntheticEvent, {discrete: true}); } @@ -116,18 +129,22 @@ const HoverResponder = { }, handleEvent( context: EventResponderContext, - props: Object, + props: HoverProps, state: HoverState, ): void { const {eventType, eventTarget, event} = context; switch (eventType) { - case 'touchstart': - // Touch devices don't have hover support + /** + * Prevent hover events when touch is being used. + */ + case 'touchstart': { if (!state.isTouched) { state.isTouched = true; } break; + } + case 'pointerover': case 'mouseover': { if ( @@ -148,7 +165,7 @@ const HoverResponder = { state.isInHitSlop = true; return; } - dispatchHoverStartEvents(context, props, state); + dispatchHoverStartEvents(context, props); state.isHovered = true; } break; @@ -172,7 +189,7 @@ const HoverResponder = { (event: any).y, ) ) { - dispatchHoverStartEvents(context, props, state); + dispatchHoverStartEvents(context, props); state.isHovered = true; state.isInHitSlop = false; } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index b79c38758b9a8..69251a1fa3ef5 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -10,27 +10,6 @@ import type {EventResponderContext} from 'events/EventTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -// const DEFAULT_PRESS_DELAY_MS = 0; -// const DEFAULT_PRESS_END_DELAY_MS = 0; -// const DEFAULT_PRESS_START_DELAY_MS = 0; -const DEFAULT_LONG_PRESS_DELAY_MS = 1000; - -const targetEventTypes = [ - {name: 'click', passive: false}, - {name: 'keydown', passive: false}, - 'pointerdown', - 'pointercancel', - 'contextmenu', -]; -const rootEventTypes = [{name: 'pointerup', passive: false}, 'scroll']; - -// In the case we don't have PointerEvents (Safari), we listen to touch events -// too -if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel'); - rootEventTypes.push({name: 'mouseup', passive: false}); -} - type PressProps = { disabled: boolean, delayLongPress: number, @@ -70,6 +49,30 @@ type PressEvent = {| type: PressEventType, |}; +// const DEFAULT_PRESS_DELAY_MS = 0; +// const DEFAULT_PRESS_END_DELAY_MS = 0; +// const DEFAULT_PRESS_START_DELAY_MS = 0; +const DEFAULT_LONG_PRESS_DELAY_MS = 500; + +const targetEventTypes = [ + {name: 'click', passive: false}, + {name: 'keydown', passive: false}, + 'pointerdown', + 'pointercancel', + 'contextmenu', +]; +const rootEventTypes = [ + {name: 'keyup', passive: false}, + {name: 'pointerup', passive: false}, + 'scroll', +]; + +// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events. +if (typeof window !== 'undefined' && window.PointerEvent === undefined) { + targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel'); + rootEventTypes.push({name: 'mouseup', passive: false}); +} + function createPressEvent( type: PressEventType, target: Element | Document, @@ -82,7 +85,7 @@ function createPressEvent( }; } -function dispatchPressEvent( +function dispatchEvent( context: EventResponderContext, state: PressState, name: PressEventType, @@ -93,23 +96,40 @@ function dispatchPressEvent( context.dispatchEvent(syntheticEvent, {discrete: true}); } +function dispatchPressChangeEvent( + context: EventResponderContext, + props: PressProps, + state: PressState, +): void { + const listener = () => { + props.onPressChange(state.isPressed); + }; + dispatchEvent(context, state, 'presschange', listener); +} + +function dispatchLongPressChangeEvent( + context: EventResponderContext, + props: PressProps, + state: PressState, +): void { + const listener = () => { + props.onLongPressChange(state.isLongPressed); + }; + dispatchEvent(context, state, 'longpresschange', listener); +} + function dispatchPressStartEvents( context: EventResponderContext, props: PressProps, state: PressState, ): void { - function dispatchPressChangeEvent(bool) { - const pressChangeEventListener = () => { - props.onPressChange(bool); - }; - dispatchPressEvent(context, state, 'presschange', pressChangeEventListener); - } + state.isPressed = true; if (props.onPressStart) { - dispatchPressEvent(context, state, 'pressstart', props.onPressStart); + dispatchEvent(context, state, 'pressstart', props.onPressStart); } if (props.onPressChange) { - dispatchPressChangeEvent(true); + dispatchPressChangeEvent(context, props, state); } if ((props.onLongPress || props.onLongPressChange) && !state.isLongPressed) { const delayLongPress = calculateDelayMS( @@ -125,31 +145,18 @@ function dispatchPressStartEvents( state.longPressTimeout = null; if (props.onLongPress) { - const longPressEventListener = e => { + const listener = e => { props.onLongPress(e); // TODO address this again at some point // if (e.nativeEvent.defaultPrevented) { // state.defaultPrevented = true; // } }; - dispatchPressEvent( - context, - state, - 'longpress', - longPressEventListener, - ); + dispatchEvent(context, state, 'longpress', listener); } if (props.onLongPressChange) { - const longPressChangeEventListener = () => { - props.onLongPressChange(true); - }; - dispatchPressEvent( - context, - state, - 'longpresschange', - longPressChangeEventListener, - ); + dispatchLongPressChangeEvent(context, props, state); } }), delayLongPress, @@ -167,24 +174,21 @@ function dispatchPressEndEvents( state.longPressTimeout = null; } if (props.onPressEnd) { - dispatchPressEvent(context, state, 'pressend', props.onPressEnd); + dispatchEvent(context, state, 'pressend', props.onPressEnd); } - if (props.onPressChange) { - const pressChangeEventListener = () => { - props.onPressChange(false); - }; - dispatchPressEvent(context, state, 'presschange', pressChangeEventListener); + + if (state.isPressed) { + state.isPressed = false; + if (props.onPressChange) { + dispatchPressChangeEvent(context, props, state); + } } - if (props.onLongPressChange && state.isLongPressed) { - const longPressChangeEventListener = () => { - props.onLongPressChange(false); - }; - dispatchPressEvent( - context, - state, - 'longpresschange', - longPressChangeEventListener, - ); + + if (state.isLongPressed) { + state.isLongPressed = false; + if (props.onLongPressChange) { + dispatchLongPressChangeEvent(context, props, state); + } } } @@ -223,15 +227,74 @@ const PressResponder = { const {eventTarget, eventType, event} = context; switch (eventType) { - case 'keydown': { + /** + * Respond to pointer events and fall back to mouse. + */ + case 'pointerdown': + case 'mousedown': { if ( - !props.onPress || - context.isTargetOwned(eventTarget) || - !isValidKeyPress((event: any).key) + !state.isPressed && + !context.isTargetOwned(eventTarget) && + !state.shouldSkipMouseAfterTouch ) { - return; + if ( + (event: any).pointerType === 'mouse' || + eventType === 'mousedown' + ) { + if ( + // Ignore right- and middle-clicks + event.button === 1 || + event.button === 2 || + // Ignore pressing on hit slop area with mouse + context.isPositionWithinTouchHitTarget( + (event: any).x, + (event: any).y, + ) + ) { + return; + } + } + state.pressTarget = eventTarget; + dispatchPressStartEvents(context, props, state); + context.addRootEventTypes(rootEventTypes); } - dispatchPressEvent(context, state, 'press', props.onPress); + break; + } + case 'pointerup': + case 'mouseup': { + if (state.isPressed) { + if (state.shouldSkipMouseAfterTouch) { + state.shouldSkipMouseAfterTouch = false; + return; + } + + const wasLongPressed = state.isLongPressed; + + dispatchPressEndEvents(context, props, state); + + if (state.pressTarget !== null && props.onPress) { + if (context.isTargetWithinElement(eventTarget, state.pressTarget)) { + if ( + !( + wasLongPressed && + props.onLongPressShouldCancelPress && + props.onLongPressShouldCancelPress() + ) + ) { + const listener = e => { + props.onPress(e); + // TODO address this again at some point + // if (e.nativeEvent.defaultPrevented) { + // state.defaultPrevented = true; + // } + }; + dispatchEvent(context, state, 'press', listener); + } + } + } + context.removeRootEventTypes(rootEventTypes); + } + state.isAnchorTouched = false; break; } @@ -239,7 +302,7 @@ const PressResponder = { * Touch event implementations are only needed for Safari, which lacks * support for pointer events. */ - case 'touchstart': + case 'touchstart': { if (!state.isPressed && !context.isTargetOwned(eventTarget)) { // We bail out of polyfilling anchor tags, given the same heuristics // explained above in regards to needing to use click events. @@ -249,21 +312,21 @@ const PressResponder = { } state.pressTarget = eventTarget; dispatchPressStartEvents(context, props, state); - state.isPressed = true; context.addRootEventTypes(rootEventTypes); } - break; + } case 'touchend': { if (state.isAnchorTouched) { + state.isAnchorTouched = false; return; } if (state.isPressed) { + const wasLongPressed = state.isLongPressed; + dispatchPressEndEvents(context, props, state); - if ( - eventType !== 'touchcancel' && - (props.onPress || props.onLongPress) - ) { + + if (eventType !== 'touchcancel' && props.onPress) { // Find if the X/Y of the end touch is still that of the original target const changedTouch = (event: any).changedTouches[0]; const doc = (eventTarget: any).ownerDocument; @@ -276,19 +339,16 @@ const PressResponder = { context.isTargetWithinEventComponent(target) ) { if ( - props.onPress && !( - state.isLongPressed && + wasLongPressed && props.onLongPressShouldCancelPress && props.onLongPressShouldCancelPress() ) ) { - dispatchPressEvent(context, state, 'press', props.onPress); + dispatchEvent(context, state, 'press', props.onPress); } } } - state.isPressed = false; - state.isLongPressed = false; state.shouldSkipMouseAfterTouch = true; context.removeRootEventTypes(rootEventTypes); } @@ -296,93 +356,58 @@ const PressResponder = { } /** - * Respond to pointer events and fall back to mouse. + * Keyboard interaction support + * TODO: determine UX for metaKey + validKeyPress interactions */ - case 'pointerdown': - case 'mousedown': { + case 'keydown': { if ( !state.isPressed && + !state.isLongPressed && !context.isTargetOwned(eventTarget) && - !state.shouldSkipMouseAfterTouch + isValidKeyPress((event: any).key) ) { - if ( - (event: any).pointerType === 'mouse' || - eventType === 'mousedown' - ) { - // Ignore if we are pressing on hit slop area with mouse - if ( - context.isPositionWithinTouchHitTarget( - (event: any).x, - (event: any).y, - ) - ) { - return; - } - // Ignore middle- and right-clicks - if (event.button === 2 || event.button === 1) { - return; - } + // Prevent spacebar press from scrolling the window + if ((event: any).key === ' ') { + (event: any).preventDefault(); } state.pressTarget = eventTarget; dispatchPressStartEvents(context, props, state); - state.isPressed = true; context.addRootEventTypes(rootEventTypes); } break; } - case 'pointerup': - case 'mouseup': { - if (state.isPressed) { - if (state.shouldSkipMouseAfterTouch) { - state.shouldSkipMouseAfterTouch = false; - return; - } + case 'keyup': { + if (state.isPressed && isValidKeyPress((event: any).key)) { + const wasLongPressed = state.isLongPressed; dispatchPressEndEvents(context, props, state); - if ( - state.pressTarget !== null && - (props.onPress || props.onLongPress) - ) { - if (context.isTargetWithinElement(eventTarget, state.pressTarget)) { - if ( - props.onPress && - !( - state.isLongPressed && - props.onLongPressShouldCancelPress && - props.onLongPressShouldCancelPress() - ) - ) { - const pressEventListener = e => { - props.onPress(e); - // TODO address this again at some point - // if (e.nativeEvent.defaultPrevented) { - // state.defaultPrevented = true; - // } - }; - dispatchPressEvent(context, state, 'press', pressEventListener); - } + if (state.pressTarget !== null && props.onPress) { + if ( + !( + wasLongPressed && + props.onLongPressShouldCancelPress && + props.onLongPressShouldCancelPress() + ) + ) { + dispatchEvent(context, state, 'press', props.onPress); } } - state.isPressed = false; - state.isLongPressed = false; context.removeRootEventTypes(rootEventTypes); } - state.isAnchorTouched = false; break; } - case 'scroll': - case 'touchcancel': case 'contextmenu': - case 'pointercancel': { + case 'pointercancel': + case 'scroll': + case 'touchcancel': { if (state.isPressed) { state.shouldSkipMouseAfterTouch = false; dispatchPressEndEvents(context, props, state); - state.isPressed = false; - state.isLongPressed = false; context.removeRootEventTypes(rootEventTypes); } break; } + case 'click': { if (state.defaultPrevented) { (event: any).preventDefault(); diff --git a/packages/react-events/src/__tests__/Focus-test.internal.js b/packages/react-events/src/__tests__/Focus-test.internal.js new file mode 100644 index 0000000000000..0b1287773e7ab --- /dev/null +++ b/packages/react-events/src/__tests__/Focus-test.internal.js @@ -0,0 +1,111 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactFeatureFlags; +let ReactDOM; +let Focus; + +const createFocusEvent = type => { + const event = document.createEvent('Event'); + event.initEvent(type, true, true); + return event; +}; + +describe('Focus event responder', () => { + let container; + + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableEventAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + Focus = require('react-events/focus'); + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); + container = null; + }); + + describe('onBlur', () => { + let onBlur, ref; + + beforeEach(() => { + onBlur = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "blur" event', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + ref.current.dispatchEvent(createFocusEvent('blur')); + expect(onBlur).toHaveBeenCalledTimes(1); + }); + }); + + describe('onFocus', () => { + let onFocus, ref; + + beforeEach(() => { + onFocus = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "focus" event', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocus).toHaveBeenCalledTimes(1); + }); + }); + + describe('onFocusChange', () => { + let onFocusChange, ref; + + beforeEach(() => { + onFocusChange = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "blur" and "focus" events', () => { + ref.current.dispatchEvent(createFocusEvent('focus')); + expect(onFocusChange).toHaveBeenCalledTimes(1); + expect(onFocusChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createFocusEvent('blur')); + expect(onFocusChange).toHaveBeenCalledTimes(2); + expect(onFocusChange).toHaveBeenCalledWith(false); + }); + }); + + it('expect displayName to show up for event component', () => { + expect(Focus.displayName).toBe('Focus'); + }); +}); diff --git a/packages/react-events/src/__tests__/Hover-test.internal.js b/packages/react-events/src/__tests__/Hover-test.internal.js index 1250a49917ab3..bd449d8f2348b 100644 --- a/packages/react-events/src/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/__tests__/Hover-test.internal.js @@ -14,6 +14,12 @@ let ReactFeatureFlags; let ReactDOM; let Hover; +const createPointerEvent = type => { + const event = document.createEvent('Event'); + event.initEvent(type, true, true); + return event; +}; + describe('Hover event responder', () => { let container; @@ -34,69 +40,154 @@ describe('Hover event responder', () => { container = null; }); - it('should support onHover', () => { - let divRef = React.createRef(); - let events = []; - - function handleOnHover(e) { - if (e) { - events.push('hover in'); - } else { - events.push('hover out'); - } - } - - function Component() { - return ( - -
Hover me!
+ describe('onHoverStart', () => { + let onHoverStart, ref; + + beforeEach(() => { + onHoverStart = jest.fn(); + ref = React.createRef(); + const element = ( + +
); - } - - ReactDOM.render(, container); - - const mouseOverEvent = document.createEvent('Event'); - mouseOverEvent.initEvent('mouseover', true, true); - divRef.current.dispatchEvent(mouseOverEvent); - - const mouseOutEvent = document.createEvent('Event'); - mouseOutEvent.initEvent('mouseout', true, true); - divRef.current.dispatchEvent(mouseOutEvent); - - expect(events).toEqual(['hover in', 'hover out']); + ReactDOM.render(element, container); + }); + + it('is called after "pointerover" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + it('is not called if "pointerover" pointerType is touch', () => { + const event = createPointerEvent('pointerover'); + event.pointerType = 'touch'; + ref.current.dispatchEvent(event); + expect(onHoverStart).not.toBeCalled(); + }); + + it('ignores browser emulated "mouseover" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + + // No PointerEvent fallbacks + it('is called after "mouseover" event', () => { + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverStart).toHaveBeenCalledTimes(1); + }); + it('is not called after "touchstart"', () => { + ref.current.dispatchEvent(createPointerEvent('touchstart')); + ref.current.dispatchEvent(createPointerEvent('touchend')); + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverStart).not.toBeCalled(); + }); + + // TODO: complete delayHoverStart tests + // describe('delayHoverStart', () => {}); }); - it('should support onHoverStart and onHoverEnd', () => { - let divRef = React.createRef(); - let events = []; - - function handleOnHoverStart() { - events.push('onHoverStart'); - } + describe('onHoverChange', () => { + let onHoverChange, ref; - function handleOnHoverEnd() { - events.push('onHoverEnd'); - } - - function Component() { - return ( - -
Hover me!
+ beforeEach(() => { + onHoverChange = jest.fn(); + ref = React.createRef(); + const element = ( + +
); - } - - ReactDOM.render(, container); + ReactDOM.render(element, container); + }); + + it('is called after "pointerover" and "pointerout" events', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + expect(onHoverChange).toHaveBeenCalledTimes(1); + expect(onHoverChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverChange).toHaveBeenCalledTimes(2); + expect(onHoverChange).toHaveBeenCalledWith(false); + }); + + // No PointerEvent fallbacks + it('is called after "mouseover" and "mouseout" events', () => { + ref.current.dispatchEvent(createPointerEvent('mouseover')); + expect(onHoverChange).toHaveBeenCalledTimes(1); + expect(onHoverChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverChange).toHaveBeenCalledTimes(2); + expect(onHoverChange).toHaveBeenCalledWith(false); + }); + }); - const mouseOverEvent = document.createEvent('Event'); - mouseOverEvent.initEvent('mouseover', true, true); - divRef.current.dispatchEvent(mouseOverEvent); + describe('onHoverEnd', () => { + let onHoverEnd, ref; - const mouseOutEvent = document.createEvent('Event'); - mouseOutEvent.initEvent('mouseout', true, true); - divRef.current.dispatchEvent(mouseOutEvent); + beforeEach(() => { + onHoverEnd = jest.fn(); + ref = React.createRef(); + const element = ( + +
+ + ); + ReactDOM.render(element, container); + }); + + it('is called after "pointerout" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('is not called if "pointerover" pointerType is touch', () => { + const event = createPointerEvent('pointerover'); + event.pointerType = 'touch'; + ref.current.dispatchEvent(event); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + expect(onHoverEnd).not.toBeCalled(); + }); + + it('ignores browser emulated "mouseout" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('is called after "pointercancel" event', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointercancel')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + it('is not called again after "pointercancel" event if it follows "pointerout"', () => { + ref.current.dispatchEvent(createPointerEvent('pointerover')); + ref.current.dispatchEvent(createPointerEvent('pointerout')); + ref.current.dispatchEvent(createPointerEvent('pointercancel')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + + // No PointerEvent fallbacks + it('is called after "mouseout" event', () => { + ref.current.dispatchEvent(createPointerEvent('mouseover')); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + it('is not called after "touchend"', () => { + ref.current.dispatchEvent(createPointerEvent('touchstart')); + ref.current.dispatchEvent(createPointerEvent('touchend')); + ref.current.dispatchEvent(createPointerEvent('mouseout')); + expect(onHoverEnd).not.toBeCalled(); + }); + + // TODO: complete delayHoverStart tests + // describe('delayHoverEnd', () => {}); + }); - expect(events).toEqual(['onHoverStart', 'onHoverEnd']); + it('expect displayName to show up for event component', () => { + expect(Hover.displayName).toBe('Hover'); }); }); diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index af3b93239e480..504f7e49d8d85 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -14,7 +14,7 @@ let ReactFeatureFlags; let ReactDOM; let Press; -const DEFAULT_LONG_PRESS_DELAY = 1000; +const DEFAULT_LONG_PRESS_DELAY = 500; const createPointerEvent = type => { const event = document.createEvent('Event'); @@ -69,12 +69,31 @@ describe('Event responder: Press', () => { expect(onPressStart).toHaveBeenCalledTimes(1); }); - it('ignores emulated "mousedown" event', () => { + it('ignores browser emulated "mousedown" event', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); ref.current.dispatchEvent(createPointerEvent('mousedown')); expect(onPressStart).toHaveBeenCalledTimes(1); }); + it('is called once after "keydown" events for Enter', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + + it('is called once after "keydown" events for Spacebar', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + + it('is not called after "keydown" for other keys', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'a'})); + expect(onPressStart).not.toBeCalled(); + }); + // No PointerEvent fallbacks it('is called after "mousedown" event', () => { ref.current.dispatchEvent(createPointerEvent('mousedown')); @@ -109,20 +128,37 @@ describe('Event responder: Press', () => { expect(onPressEnd).toHaveBeenCalledTimes(1); }); - it('ignores emulated "mouseup" event', () => { + it('ignores browser emulated "mouseup" event', () => { ref.current.dispatchEvent(createPointerEvent('touchstart')); ref.current.dispatchEvent(createPointerEvent('touchend')); ref.current.dispatchEvent(createPointerEvent('mouseup')); expect(onPressEnd).toHaveBeenCalledTimes(1); }); + it('is called after "keyup" event for Enter', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + + it('is called after "keyup" event for Spacebar', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: ' '})); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + + it('is not called after "keyup" event for other keys', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'a'})); + expect(onPressEnd).not.toBeCalled(); + }); + // No PointerEvent fallbacks it('is called after "mouseup" event', () => { ref.current.dispatchEvent(createPointerEvent('mousedown')); ref.current.dispatchEvent(createPointerEvent('mouseup')); expect(onPressEnd).toHaveBeenCalledTimes(1); }); - it('is called after "touchend" event', () => { ref.current.dispatchEvent(createPointerEvent('touchstart')); ref.current.dispatchEvent(createPointerEvent('touchend')); @@ -155,6 +191,33 @@ describe('Event responder: Press', () => { expect(onPressChange).toHaveBeenCalledTimes(2); expect(onPressChange).toHaveBeenCalledWith(false); }); + + it('is called after valid "keydown" and "keyup" events', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + + // No PointerEvent fallbacks + it('is called after "mousedown" and "mouseup" events', () => { + ref.current.dispatchEvent(createPointerEvent('mousedown')); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('mouseup')); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + it('is called after "touchstart" and "touchend" events', () => { + ref.current.dispatchEvent(createPointerEvent('touchstart')); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + ref.current.dispatchEvent(createPointerEvent('touchend')); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); }); describe('onPress', () => { @@ -176,6 +239,20 @@ describe('Event responder: Press', () => { ref.current.dispatchEvent(createPointerEvent('pointerup')); expect(onPress).toHaveBeenCalledTimes(1); }); + + it('is called after valid "keyup" event', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + // No PointerEvent fallbacks + // TODO: jsdom missing APIs + //it('is called after "touchend" event', () => { + //ref.current.dispatchEvent(createPointerEvent('touchstart')); + //ref.current.dispatchEvent(createPointerEvent('touchend')); + //expect(onPress).toHaveBeenCalledTimes(1); + //}); }); describe('onLongPress', () => { @@ -192,7 +269,7 @@ describe('Event responder: Press', () => { ReactDOM.render(element, container); }); - it('is called if press lasts default delay', () => { + it('is called if "pointerdown" lasts default delay', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); expect(onLongPress).not.toBeCalled(); @@ -200,7 +277,7 @@ describe('Event responder: Press', () => { expect(onLongPress).toHaveBeenCalledTimes(1); }); - it('is not called if press is released before delay', () => { + it('is not called if "pointerup" is dispatched before delay', () => { ref.current.dispatchEvent(createPointerEvent('pointerdown')); jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); ref.current.dispatchEvent(createPointerEvent('pointerup')); @@ -208,6 +285,22 @@ describe('Event responder: Press', () => { expect(onLongPress).not.toBeCalled(); }); + it('is called if valid "keydown" lasts default delay', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); + expect(onLongPress).not.toBeCalled(); + jest.advanceTimersByTime(1); + expect(onLongPress).toHaveBeenCalledTimes(1); + }); + + it('is not called if valid "keyup" is dispatched before delay', () => { + ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1); + ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'})); + jest.advanceTimersByTime(1); + expect(onLongPress).not.toBeCalled(); + }); + describe('delayLongPress', () => { it('can be configured', () => { const element = ( @@ -339,7 +432,7 @@ describe('Event responder: Press', () => { describe('nested responders', () => { it('dispatch events in the correct order', () => { - let events = []; + const events = []; const ref = React.createRef(); const createEventHandler = msg => () => { events.push(msg); @@ -385,13 +478,6 @@ describe('Event responder: Press', () => { 'outer: onPressChange', 'outer: onPress', ]); - - events = []; - ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); - // TODO update this test once we have a form of stopPropagation in - // the responder system again. This test had to be updated because - // we have removed stopPropagation() from synthetic events. - expect(events).toEqual(['keydown', 'inner: onPress', 'outer: onPress']); }); });