From aaa151fad3359012bc970546184ee37b391db98e Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Fri, 6 Sep 2019 17:30:15 -0700 Subject: [PATCH] [react-ui] usePress from useKeyboard and useTap This implements 'usePress' in user-space as a combination of 'useKeyboard' and 'useTap'. The existing 'usePress' API is preserved for now. The previous implementation is moved to 'PressLegacy'. --- .../react-ui/accessibility/src/FocusGrid.js | 4 +- .../accessibility/src/ReactTabFocus.js | 5 +- packages/react-ui/events/press-legacy.js | 12 + packages/react-ui/events/src/dom/Keyboard.js | 11 +- packages/react-ui/events/src/dom/Press.js | 934 ++------------ .../react-ui/events/src/dom/PressLegacy.js | 864 +++++++++++++ packages/react-ui/events/src/dom/Tap.js | 65 +- .../dom/__tests__/Keyboard-test.internal.js | 94 +- .../MixedResponders-test-internal.js | 58 +- .../src/dom/__tests__/Press-test.internal.js | 554 +------- .../__tests__/PressLegacy-test.internal.js | 1121 +++++++++++++++++ .../src/dom/__tests__/Tap-test.internal.js | 16 +- packages/react-ui/npm/drag.js | 4 +- packages/react-ui/npm/focus.js | 4 +- packages/react-ui/npm/hover.js | 4 +- packages/react-ui/npm/input.js | 4 +- packages/react-ui/npm/keyboard.js | 4 +- packages/react-ui/npm/press-legacy.js | 7 + packages/react-ui/npm/press.js | 4 +- packages/react-ui/npm/scroll.js | 4 +- packages/react-ui/npm/swipe.js | 4 +- packages/react-ui/npm/tap.js | 4 +- packages/react-ui/package.json | 1 + scripts/rollup/bundles.js | 15 + scripts/rollup/modules.js | 2 + scripts/shared/pathsByLanguageVersion.js | 3 +- 26 files changed, 2364 insertions(+), 1438 deletions(-) create mode 100644 packages/react-ui/events/press-legacy.js create mode 100644 packages/react-ui/events/src/dom/PressLegacy.js create mode 100644 packages/react-ui/events/src/dom/__tests__/PressLegacy-test.internal.js create mode 100644 packages/react-ui/npm/press-legacy.js diff --git a/packages/react-ui/accessibility/src/FocusGrid.js b/packages/react-ui/accessibility/src/FocusGrid.js index c0de9c1d052aa..d7dcfe866fbf6 100644 --- a/packages/react-ui/accessibility/src/FocusGrid.js +++ b/packages/react-ui/accessibility/src/FocusGrid.js @@ -7,11 +7,11 @@ * @flow */ -import type {KeyboardEvent} from 'react-ui/events/src/dom/Keyboard'; +import type {KeyboardEvent} from 'react-ui/events/keyboard'; import React from 'react'; import {tabFocusableImpl} from './TabbableScope'; -import {useKeyboard} from '../../events/keyboard'; +import {useKeyboard} from 'react-ui/events/keyboard'; type GridComponentProps = { children: React.Node, diff --git a/packages/react-ui/accessibility/src/ReactTabFocus.js b/packages/react-ui/accessibility/src/ReactTabFocus.js index 3d39251c9bb84..d09808ea3732f 100644 --- a/packages/react-ui/accessibility/src/ReactTabFocus.js +++ b/packages/react-ui/accessibility/src/ReactTabFocus.js @@ -8,16 +8,17 @@ */ import type {ReactScopeMethods} from 'shared/ReactTypes'; -import type {KeyboardEvent} from 'react-ui/events/src/dom/Keyboard'; +import type {KeyboardEvent} from 'react-ui/events/keyboard'; import React from 'react'; import {TabbableScope} from './TabbableScope'; -import {useKeyboard} from '../../events/keyboard'; +import {useKeyboard} from 'react-ui/events/keyboard'; type TabFocusControllerProps = { children: React.Node, contain?: boolean, }; + const {useRef} = React; function getTabbableNodes(scope: ReactScopeMethods) { diff --git a/packages/react-ui/events/press-legacy.js b/packages/react-ui/events/press-legacy.js new file mode 100644 index 0000000000000..4b2d60e7f81d0 --- /dev/null +++ b/packages/react-ui/events/press-legacy.js @@ -0,0 +1,12 @@ +/** + * 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. + * + * @flow + */ + +'use strict'; + +module.exports = require('./src/dom/PressLegacy'); diff --git a/packages/react-ui/events/src/dom/Keyboard.js b/packages/react-ui/events/src/dom/Keyboard.js index 9ccad91b31d3f..426e9950a4d0c 100644 --- a/packages/react-ui/events/src/dom/Keyboard.js +++ b/packages/react-ui/events/src/dom/Keyboard.js @@ -27,6 +27,7 @@ type KeyboardProps = {| onClick?: (e: KeyboardEvent) => ?boolean, onKeyDown?: (e: KeyboardEvent) => ?boolean, onKeyUp?: (e: KeyboardEvent) => ?boolean, + preventClick?: boolean, preventKeys?: PreventKeysArray, |}; @@ -256,6 +257,12 @@ const keyboardResponderImpl = { ); } } else if (type === 'click' && isVirtualClick(event)) { + if (props.preventClick !== false) { + // 'click' occurs before or after 'keyup', and may need native + // behavior prevented + nativeEvent.preventDefault(); + state.defaultPrevented = true; + } const onClick = props.onClick; if (onClick != null) { dispatchKeyboardEvent( @@ -266,10 +273,6 @@ const keyboardResponderImpl = { state.defaultPrevented, ); } - if (state.defaultPrevented && !nativeEvent.defaultPrevented) { - // 'click' occurs before 'keyup' and may need native behavior prevented - nativeEvent.preventDefault(); - } } else if (type === 'keyup') { state.isActive = false; const onKeyUp = props.onKeyUp; diff --git a/packages/react-ui/events/src/dom/Press.js b/packages/react-ui/events/src/dom/Press.js index 6ad2bc0d5f2dd..c7e52791c530a 100644 --- a/packages/react-ui/events/src/dom/Press.js +++ b/packages/react-ui/events/src/dom/Press.js @@ -7,858 +7,178 @@ * @flow */ -import type { - ReactDOMResponderEvent, - ReactDOMResponderContext, - PointerType, -} from 'shared/ReactDOMTypes'; -import type { - EventPriority, - ReactEventResponderListener, -} from 'shared/ReactTypes'; +import type {PointerType} from 'shared/ReactDOMTypes'; import React from 'react'; -import {DiscreteEvent, UserBlockingEvent} from 'shared/ReactTypes'; +import {useTap} from 'react-ui/events/tap'; +import {useKeyboard} from 'react-ui/events/keyboard'; -type PressProps = {| - disabled: boolean, - pressRetentionOffset: { - top: number, - right: number, - bottom: number, - left: number, - }, - preventDefault: boolean, - onPress: (e: PressEvent) => void, - onPressChange: boolean => void, - onPressEnd: (e: PressEvent) => void, - onPressMove: (e: PressEvent) => void, - onPressStart: (e: PressEvent) => void, -|}; +const emptyObject = {}; -type PressState = { - activationPosition: null | $ReadOnly<{| - x: number, - y: number, - |}>, - addedRootEvents: boolean, - buttons: 0 | 1 | 4, - isActivePressed: boolean, - isActivePressStart: boolean, - isPressed: boolean, - isPressWithinResponderRegion: boolean, - pointerType: PointerType, - pressTarget: null | Element | Document, - responderRegionOnActivation: null | $ReadOnly<{| - bottom: number, - left: number, - right: number, - top: number, - |}>, - responderRegionOnDeactivation: null | $ReadOnly<{| - bottom: number, - left: number, - right: number, - top: number, - |}>, - ignoreEmulatedMouseEvents: boolean, - activePointerId: null | number, - shouldPreventClick: boolean, - touchEvent: null | Touch, -}; +type PressProps = $ReadOnly<{| + disabled?: boolean, + preventDefault?: boolean, + onPress?: (e: PressEvent) => void, + onPressChange?: boolean => void, + onPressEnd?: (e: PressEvent) => void, + onPressMove?: (e: PressEvent) => void, + onPressStart?: (e: PressEvent) => void, +|}>; type PressEventType = - | 'press' - | 'pressmove' | 'pressstart' + | 'presschange' + | 'pressmove' | 'pressend' - | 'presschange'; + | 'press'; type PressEvent = {| altKey: boolean, - buttons: 0 | 1 | 4, - clientX: null | number, - clientY: null | number, + buttons: null | 0 | 1 | 4, ctrlKey: boolean, defaultPrevented: boolean, + key: null | string, metaKey: boolean, - pageX: null | number, - pageY: null | number, + pageX: number, + pageY: number, pointerType: PointerType, - screenX: null | number, - screenY: null | number, shiftKey: boolean, - target: Element | Document, + target: null | Element, timeStamp: number, type: PressEventType, - x: null | number, - y: null | number, + x: number, + y: number, |}; -const hasPointerEvents = - typeof window !== 'undefined' && window.PointerEvent !== undefined; - -const isMac = - typeof window !== 'undefined' && window.navigator != null - ? /^Mac/.test(window.navigator.platform) - : false; - -const DEFAULT_PRESS_RETENTION_OFFSET = { - bottom: 20, - top: 20, - left: 20, - right: 20, -}; - -const targetEventTypes = hasPointerEvents - ? ['keydown_active', 'pointerdown', 'click_active'] - : ['keydown_active', 'touchstart', 'mousedown', 'click_active']; - -const rootEventTypes = hasPointerEvents - ? ['pointerup', 'pointermove', 'pointercancel', 'click', 'keyup', 'scroll'] - : [ - 'click', - 'keyup', - 'scroll', - 'mousemove', - 'touchmove', - 'touchcancel', - // Used as a 'cancel' signal for mouse interactions - 'dragstart', - 'mouseup', - 'touchend', - ]; - -function isFunction(obj): boolean { - return typeof obj === 'function'; -} - -function createPressEvent( - context: ReactDOMResponderContext, - type: PressEventType, - target: Element | Document, - pointerType: PointerType, - event: ?ReactDOMResponderEvent, - touchEvent: null | Touch, - defaultPrevented: boolean, - state: PressState, -): PressEvent { - const timeStamp = context.getTimeStamp(); - let clientX = null; - let clientY = null; - let pageX = null; - let pageY = null; - let screenX = null; - let screenY = null; - let altKey = false; - let ctrlKey = false; - let metaKey = false; - let shiftKey = false; - - if (event) { - const nativeEvent = (event.nativeEvent: any); - ({altKey, ctrlKey, metaKey, shiftKey} = nativeEvent); - // Only check for one property, checking for all of them is costly. We can assume - // if clientX exists, so do the rest. - let eventObject; - eventObject = (touchEvent: any) || (nativeEvent: any); - if (eventObject) { - ({clientX, clientY, pageX, pageY, screenX, screenY} = eventObject); - } - } +function createGestureState(e: any, type: PressEventType): PressEvent { return { - altKey, - buttons: state.buttons, - clientX, - clientY, - ctrlKey, - defaultPrevented, - metaKey, - pageX, - pageY, - pointerType, - screenX, - screenY, - shiftKey, - target, - timeStamp, + altKey: e.altKey, + buttons: e.buttons, + ctrlKey: e.ctrlKey, + defaultPrevented: e.defaultPrevented, + key: e.key, + metaKey: e.metaKey, + pageX: e.pageX, + pageY: e.pageX, + pointerType: e.pointerType, + shiftKey: e.shiftKey, + target: e.target, + timeStamp: e.timeStamp, type, - x: clientX, - y: clientY, + x: e.x, + y: e.y, }; } -function dispatchEvent( - event: ?ReactDOMResponderEvent, - listener: any => void, - context: ReactDOMResponderContext, - state: PressState, - name: PressEventType, - eventPriority: EventPriority, -): void { - const target = ((state.pressTarget: any): Element | Document); - const pointerType = state.pointerType; - const defaultPrevented = - (event != null && event.nativeEvent.defaultPrevented === true) || - (name === 'press' && state.shouldPreventClick); - const touchEvent = state.touchEvent; - const syntheticEvent = createPressEvent( - context, - name, - target, - pointerType, - event, - touchEvent, - defaultPrevented, - state, - ); - context.dispatchEvent(syntheticEvent, listener, eventPriority); -} - -function dispatchPressChangeEvent( - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, -): void { - const onPressChange = props.onPressChange; - if (isFunction(onPressChange)) { - const bool = state.isActivePressed; - context.dispatchEvent(bool, onPressChange, DiscreteEvent); - } -} - -function dispatchPressStartEvents( - event: ReactDOMResponderEvent, - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, -): void { - state.isPressed = true; - - if (!state.isActivePressStart) { - state.isActivePressStart = true; - const nativeEvent: any = event.nativeEvent; - const {clientX: x, clientY: y} = state.touchEvent || nativeEvent; - const wasActivePressed = state.isActivePressed; - state.isActivePressed = true; - if (x !== undefined && y !== undefined) { - state.activationPosition = {x, y}; - } - const onPressStart = props.onPressStart; - - if (isFunction(onPressStart)) { - dispatchEvent( - event, - onPressStart, - context, - state, - 'pressstart', - DiscreteEvent, - ); - } - if (!wasActivePressed) { - dispatchPressChangeEvent(context, props, state); - } - } -} - -function dispatchPressEndEvents( - event: ?ReactDOMResponderEvent, - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, -): void { - state.isActivePressStart = false; - state.isPressed = false; - - if (state.isActivePressed) { - state.isActivePressed = false; - const onPressEnd = props.onPressEnd; - - if (isFunction(onPressEnd)) { - dispatchEvent( - event, - onPressEnd, - context, - state, - 'pressend', - DiscreteEvent, - ); - } - dispatchPressChangeEvent(context, props, state); - } - - state.responderRegionOnDeactivation = null; -} - -function dispatchCancel( - event: ReactDOMResponderEvent, - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, -): void { - state.touchEvent = null; - if (state.isPressed) { - state.ignoreEmulatedMouseEvents = false; - dispatchPressEndEvents(event, context, props, state); - } - removeRootEventTypes(context, state); -} - -function isValidKeyboardEvent(nativeEvent: Object): boolean { - const {key, target} = nativeEvent; - const {tagName, isContentEditable} = target; - // Accessibility for keyboards. Space and Enter only. - // "Spacebar" is for IE 11 +function isValidKey(e): boolean { + const {key, target} = e; + const {tagName, isContentEditable} = (target: any); return ( - (key === 'Enter' || key === ' ' || key === 'Spacebar') && + (key === 'Enter' || key === ' ') && (tagName !== 'INPUT' && tagName !== 'TEXTAREA' && isContentEditable !== true) ); } -// TODO: account for touch hit slop -function calculateResponderRegion( - context: ReactDOMResponderContext, - target: Element, - props: PressProps, -) { - const pressRetentionOffset = context.objectAssign( - {}, - DEFAULT_PRESS_RETENTION_OFFSET, - props.pressRetentionOffset, - ); - - let {left, right, bottom, top} = target.getBoundingClientRect(); - - if (pressRetentionOffset) { - if (pressRetentionOffset.bottom != null) { - bottom += pressRetentionOffset.bottom; - } - if (pressRetentionOffset.left != null) { - left -= pressRetentionOffset.left; - } - if (pressRetentionOffset.right != null) { - right += pressRetentionOffset.right; - } - if (pressRetentionOffset.top != null) { - top -= pressRetentionOffset.top; - } - } - - return { - bottom, - top, - left, - right, - }; -} - -function getTouchFromPressEvent(nativeEvent: TouchEvent): null | Touch { - const targetTouches = nativeEvent.targetTouches; - if (targetTouches.length > 0) { - return targetTouches[0]; - } - return null; -} - -function unmountResponder( - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, -): void { - if (state.isPressed) { - removeRootEventTypes(context, state); - dispatchPressEndEvents(null, context, props, state); - } -} - -function addRootEventTypes( - context: ReactDOMResponderContext, - state: PressState, -): void { - if (!state.addedRootEvents) { - state.addedRootEvents = true; - context.addRootEventTypes(rootEventTypes); - } -} - -function removeRootEventTypes( - context: ReactDOMResponderContext, - state: PressState, -): void { - if (state.addedRootEvents) { - state.addedRootEvents = false; - context.removeRootEventTypes(rootEventTypes); - } -} - -function getTouchById( - nativeEvent: TouchEvent, - pointerId: null | number, -): null | Touch { - const changedTouches = nativeEvent.changedTouches; - for (let i = 0; i < changedTouches.length; i++) { - const touch = changedTouches[i]; - if (touch.identifier === pointerId) { - return touch; - } - } - return null; -} - -function getTouchTarget(context: ReactDOMResponderContext, touchEvent: Touch) { - const doc = context.getActiveDocument(); - return doc.elementFromPoint(touchEvent.clientX, touchEvent.clientY); -} - -function updateIsPressWithinResponderRegion( - nativeEventOrTouchEvent: Event | Touch, - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, -): void { - // Calculate the responder region we use for deactivation if not - // already done during move event. - if (state.responderRegionOnDeactivation == null) { - state.responderRegionOnDeactivation = calculateResponderRegion( - context, - ((state.pressTarget: any): Element), - props, - ); - } - const {responderRegionOnActivation, responderRegionOnDeactivation} = state; - let left, top, right, bottom; - - if (responderRegionOnActivation != null) { - left = responderRegionOnActivation.left; - top = responderRegionOnActivation.top; - right = responderRegionOnActivation.right; - bottom = responderRegionOnActivation.bottom; - - if (responderRegionOnDeactivation != null) { - left = Math.min(left, responderRegionOnDeactivation.left); - top = Math.min(top, responderRegionOnDeactivation.top); - right = Math.max(right, responderRegionOnDeactivation.right); - bottom = Math.max(bottom, responderRegionOnDeactivation.bottom); - } - } - const {clientX: x, clientY: y} = (nativeEventOrTouchEvent: any); - - state.isPressWithinResponderRegion = - left != null && - right != null && - top != null && - bottom != null && - x !== null && - y !== null && - (x >= left && x <= right && y >= top && y <= bottom); -} - -// After some investigation work, screen reader virtual -// clicks (NVDA, Jaws, VoiceOver) do not have co-ords associated with the click -// event and "detail" is always 0 (where normal clicks are > 0) -function isScreenReaderVirtualClick(nativeEvent): boolean { - return ( - nativeEvent.detail === 0 && - nativeEvent.screenX === 0 && - nativeEvent.screenY === 0 && - nativeEvent.clientX === 0 && - nativeEvent.clientY === 0 - ); -} - -function targetIsDocument(target: null | Node): boolean { - // When target is null, it is the root - return target === null || target.nodeType === 9; -} - -const pressResponderImpl = { - targetEventTypes, - getInitialState(): PressState { - return { - activationPosition: null, - addedRootEvents: false, - buttons: 0, - isActivePressed: false, - isActivePressStart: false, - isPressed: false, - isPressWithinResponderRegion: true, - pointerType: '', - pressTarget: null, - responderRegionOnActivation: null, - responderRegionOnDeactivation: null, - ignoreEmulatedMouseEvents: false, - activePointerId: null, - shouldPreventClick: false, - touchEvent: null, - }; - }, - onEvent( - event: ReactDOMResponderEvent, - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, - ): void { - const {pointerType, type} = event; - - if (props.disabled) { - removeRootEventTypes(context, state); - dispatchPressEndEvents(event, context, props, state); - state.ignoreEmulatedMouseEvents = false; - return; - } - const nativeEvent: any = event.nativeEvent; - const isPressed = state.isPressed; - - switch (type) { - // START - case 'pointerdown': - case 'keydown': - case 'mousedown': - case 'touchstart': { - if (!isPressed) { - const isTouchEvent = type === 'touchstart'; - const isPointerEvent = type === 'pointerdown'; - const isKeyboardEvent = pointerType === 'keyboard'; - const isMouseEvent = pointerType === 'mouse'; - - // Ignore emulated mouse events - if (type === 'mousedown' && state.ignoreEmulatedMouseEvents) { - return; - } - - state.shouldPreventClick = false; - if (isTouchEvent) { - state.ignoreEmulatedMouseEvents = true; - } else if (isKeyboardEvent) { - // Ignore unrelated key events - if (isValidKeyboardEvent(nativeEvent)) { - const { - altKey, - ctrlKey, - metaKey, - shiftKey, - } = (nativeEvent: MouseEvent); - if (nativeEvent.key === ' ') { - nativeEvent.preventDefault(); - } else if ( - props.preventDefault !== false && - !shiftKey && - !metaKey && - !ctrlKey && - !altKey - ) { - state.shouldPreventClick = true; - } - } else { - return; - } - } - - // We set these here, before the button check so we have this - // data around for handling of the context menu - state.pointerType = pointerType; - const pressTarget = (state.pressTarget = context.getResponderNode()); - if (isPointerEvent) { - state.activePointerId = nativeEvent.pointerId; - } else if (isTouchEvent) { - const touchEvent = getTouchFromPressEvent(nativeEvent); - if (touchEvent === null) { - return; - } - state.touchEvent = touchEvent; - state.activePointerId = touchEvent.identifier; - } - - // Ignore any device buttons except primary/middle and touch/pen contact. - // Additionally we ignore primary-button + ctrl-key with Macs as that - // acts like right-click and opens the contextmenu. - if ( - nativeEvent.buttons === 2 || - nativeEvent.buttons > 4 || - (isMac && isMouseEvent && nativeEvent.ctrlKey) - ) { - return; - } - // Exclude document targets - if (!targetIsDocument(pressTarget)) { - state.responderRegionOnActivation = calculateResponderRegion( - context, - ((pressTarget: any): Element), - props, - ); - } - state.responderRegionOnDeactivation = null; - state.isPressWithinResponderRegion = true; - state.buttons = nativeEvent.buttons; - dispatchPressStartEvents(event, context, props, state); - addRootEventTypes(context, state); - } else { - // Prevent spacebar press from scrolling the window - if (isValidKeyboardEvent(nativeEvent) && nativeEvent.key === ' ') { - nativeEvent.preventDefault(); - } +/** + * The lack of built-in composition for gesture responders means we have to + * selectively ignore callbacks from useKeyboard or useTap if the other is + * active. + */ +export function usePress(props: PressProps) { + const safeProps = props || emptyObject; + const { + disabled, + preventDefault, + onPress, + onPressChange, + onPressEnd, + onPressMove, + onPressStart, + } = safeProps; + + const [active, updateActive] = React.useState(null); + + const tap = useTap({ + disabled: disabled || active === 'keyboard', + preventDefault, + onTapStart(e) { + if (active == null) { + updateActive('tap'); + if (onPressStart != null) { + onPressStart(createGestureState(e, 'pressstart')); } - break; } - - case 'click': { - if (state.shouldPreventClick) { - nativeEvent.preventDefault(); + }, + onTapChange: onPressChange, + onTapUpdate(e) { + if (active === 'tap') { + if (onPressMove != null) { + onPressMove(createGestureState(e, 'pressmove')); } - const onPress = props.onPress; - - if (isFunction(onPress) && isScreenReaderVirtualClick(nativeEvent)) { - state.pointerType = 'keyboard'; - state.pressTarget = context.getResponderNode(); - const preventDefault = props.preventDefault; - - if (preventDefault !== false) { - nativeEvent.preventDefault(); - } - dispatchEvent(event, onPress, context, state, 'press', DiscreteEvent); - } - break; } - } - }, - onRootEvent( - event: ReactDOMResponderEvent, - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, - ): void { - let {pointerType, target, type} = event; - - const nativeEvent: any = event.nativeEvent; - const isPressed = state.isPressed; - const activePointerId = state.activePointerId; - const previousPointerType = state.pointerType; - - switch (type) { - // MOVE - case 'pointermove': - case 'mousemove': - case 'touchmove': { - let touchEvent; - // Ignore emulated events (pointermove will dispatch touch and mouse events) - // Ignore pointermove events during a keyboard press. - if (previousPointerType !== pointerType) { - return; - } - if ( - type === 'pointermove' && - activePointerId !== nativeEvent.pointerId - ) { - return; - } else if (type === 'touchmove') { - touchEvent = getTouchById(nativeEvent, activePointerId); - if (touchEvent === null) { - return; - } - state.touchEvent = touchEvent; + }, + onTapEnd(e) { + if (active === 'tap') { + if (onPressEnd != null) { + onPressEnd(createGestureState(e, 'pressend')); } - const pressTarget = state.pressTarget; - - if (pressTarget !== null && !targetIsDocument(pressTarget)) { - if ( - pointerType === 'mouse' && - context.isTargetWithinNode(target, pressTarget) - ) { - state.isPressWithinResponderRegion = true; - } else { - // Calculate the responder region we use for deactivation, as the - // element dimensions may have changed since activation. - updateIsPressWithinResponderRegion( - touchEvent || nativeEvent, - context, - props, - state, - ); - } - } - - if (state.isPressWithinResponderRegion) { - if (isPressed) { - const onPressMove = props.onPressMove; - - if (isFunction(onPressMove)) { - dispatchEvent( - event, - onPressMove, - context, - state, - 'pressmove', - UserBlockingEvent, - ); - } - } else { - dispatchPressStartEvents(event, context, props, state); - } - } else { - dispatchPressEndEvents(event, context, props, state); + if (onPress != null && e.buttons !== 4) { + onPress(createGestureState(e, 'press')); } - break; + updateActive(null); } - - // END - case 'pointerup': - case 'keyup': - case 'mouseup': - case 'touchend': { - if (isPressed) { - const buttons = state.buttons; - let isKeyboardEvent = false; - let touchEvent; - if ( - type === 'pointerup' && - activePointerId !== nativeEvent.pointerId - ) { - return; - } else if (type === 'touchend') { - touchEvent = getTouchById(nativeEvent, activePointerId); - if (touchEvent === null) { - return; - } - state.touchEvent = touchEvent; - target = getTouchTarget(context, touchEvent); - } else if (type === 'keyup') { - // Ignore unrelated keyboard events - if (!isValidKeyboardEvent(nativeEvent)) { - return; - } - isKeyboardEvent = true; - removeRootEventTypes(context, state); - } else if (buttons === 4) { - // Remove the root events here as no 'click' event is dispatched when this 'button' is pressed. - removeRootEventTypes(context, state); - } - - // Determine whether to call preventDefault on subsequent native events. - if ( - context.isTargetWithinResponder(target) && - context.isTargetWithinHostComponent(target, 'a') - ) { - const { - altKey, - ctrlKey, - metaKey, - shiftKey, - } = (nativeEvent: MouseEvent); - // Check "open in new window/tab" and "open context menu" key modifiers - const preventDefault = props.preventDefault; - - if ( - preventDefault !== false && - !shiftKey && - !metaKey && - !ctrlKey && - !altKey - ) { - state.shouldPreventClick = true; - } - } - - const pressTarget = state.pressTarget; - dispatchPressEndEvents(event, context, props, state); - const onPress = props.onPress; - - if (pressTarget !== null && isFunction(onPress)) { - if ( - !isKeyboardEvent && - pressTarget !== null && - !targetIsDocument(pressTarget) - ) { - if ( - pointerType === 'mouse' && - context.isTargetWithinNode(target, pressTarget) - ) { - state.isPressWithinResponderRegion = true; - } else { - // If the event target isn't within the press target, check if we're still - // within the responder region. The region may have changed if the - // element's layout was modified after activation. - updateIsPressWithinResponderRegion( - touchEvent || nativeEvent, - context, - props, - state, - ); - } - } - - if (state.isPressWithinResponderRegion && buttons !== 4) { - dispatchEvent( - event, - onPress, - context, - state, - 'press', - DiscreteEvent, - ); - } - } - state.touchEvent = null; - } else if (type === 'mouseup') { - state.ignoreEmulatedMouseEvents = false; + }, + onTapCancel(e) { + if (active === 'tap') { + if (onPressEnd != null) { + onPressEnd(createGestureState(e, 'pressend')); } - break; + updateActive(null); } - - case 'click': { - // "keyup" occurs after "click" - if (previousPointerType !== 'keyboard') { - removeRootEventTypes(context, state); - } - break; + }, + }); + + const keyboard = useKeyboard({ + disabled: disabled || active === 'tap', + preventClick: preventDefault !== false, + preventKeys: preventDefault !== false ? [' ', 'Enter'] : [], + onClick(e) { + if (active == null && onPress != null) { + onPress(createGestureState(e, 'press')); } - - // CANCEL - case 'scroll': { - // We ignore incoming scroll events when using mouse events - if (previousPointerType === 'mouse') { - return; + }, + onKeyDown(e) { + if (active == null && isValidKey(e)) { + updateActive('keyboard'); + if (onPressStart != null) { + onPressStart(createGestureState(e, 'pressstart')); } - const pressTarget = state.pressTarget; - const scrollTarget = nativeEvent.target; - const doc = context.getActiveDocument(); - // If the scroll target is the document or if the press target - // is inside the scroll target, then this a scroll that should - // trigger a cancel. - if ( - pressTarget !== null && - (scrollTarget === doc || - context.isTargetWithinNode(pressTarget, scrollTarget)) - ) { - dispatchCancel(event, context, props, state); + if (onPressChange != null) { + onPressChange(true); } - break; + // stop propagation + return false; } - case 'pointercancel': - case 'touchcancel': - case 'dragstart': { - dispatchCancel(event, context, props, state); + }, + onKeyUp(e) { + if (active === 'keyboard' && isValidKey(e)) { + if (onPressChange != null) { + onPressChange(false); + } + if (onPressEnd != null) { + onPressEnd(createGestureState(e, 'pressend')); + } + if (onPress != null) { + onPress(createGestureState(e, 'press')); + } + updateActive(null); + // stop propagation + return false; } - } - }, - onUnmount( - context: ReactDOMResponderContext, - props: PressProps, - state: PressState, - ) { - unmountResponder(context, props, state); - }, -}; - -export const PressResponder = React.unstable_createResponder( - 'Press', - pressResponderImpl, -); + }, + }); -export function usePress( - props: PressProps, -): ReactEventResponderListener { - return React.unstable_useResponder(PressResponder, props); + return [tap, keyboard]; } diff --git a/packages/react-ui/events/src/dom/PressLegacy.js b/packages/react-ui/events/src/dom/PressLegacy.js new file mode 100644 index 0000000000000..6ad2bc0d5f2dd --- /dev/null +++ b/packages/react-ui/events/src/dom/PressLegacy.js @@ -0,0 +1,864 @@ +/** + * 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. + * + * @flow + */ + +import type { + ReactDOMResponderEvent, + ReactDOMResponderContext, + PointerType, +} from 'shared/ReactDOMTypes'; +import type { + EventPriority, + ReactEventResponderListener, +} from 'shared/ReactTypes'; + +import React from 'react'; +import {DiscreteEvent, UserBlockingEvent} from 'shared/ReactTypes'; + +type PressProps = {| + disabled: boolean, + pressRetentionOffset: { + top: number, + right: number, + bottom: number, + left: number, + }, + preventDefault: boolean, + onPress: (e: PressEvent) => void, + onPressChange: boolean => void, + onPressEnd: (e: PressEvent) => void, + onPressMove: (e: PressEvent) => void, + onPressStart: (e: PressEvent) => void, +|}; + +type PressState = { + activationPosition: null | $ReadOnly<{| + x: number, + y: number, + |}>, + addedRootEvents: boolean, + buttons: 0 | 1 | 4, + isActivePressed: boolean, + isActivePressStart: boolean, + isPressed: boolean, + isPressWithinResponderRegion: boolean, + pointerType: PointerType, + pressTarget: null | Element | Document, + responderRegionOnActivation: null | $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, + responderRegionOnDeactivation: null | $ReadOnly<{| + bottom: number, + left: number, + right: number, + top: number, + |}>, + ignoreEmulatedMouseEvents: boolean, + activePointerId: null | number, + shouldPreventClick: boolean, + touchEvent: null | Touch, +}; + +type PressEventType = + | 'press' + | 'pressmove' + | 'pressstart' + | 'pressend' + | 'presschange'; + +type PressEvent = {| + altKey: boolean, + buttons: 0 | 1 | 4, + clientX: null | number, + clientY: null | number, + ctrlKey: boolean, + defaultPrevented: boolean, + metaKey: boolean, + pageX: null | number, + pageY: null | number, + pointerType: PointerType, + screenX: null | number, + screenY: null | number, + shiftKey: boolean, + target: Element | Document, + timeStamp: number, + type: PressEventType, + x: null | number, + y: null | number, +|}; + +const hasPointerEvents = + typeof window !== 'undefined' && window.PointerEvent !== undefined; + +const isMac = + typeof window !== 'undefined' && window.navigator != null + ? /^Mac/.test(window.navigator.platform) + : false; + +const DEFAULT_PRESS_RETENTION_OFFSET = { + bottom: 20, + top: 20, + left: 20, + right: 20, +}; + +const targetEventTypes = hasPointerEvents + ? ['keydown_active', 'pointerdown', 'click_active'] + : ['keydown_active', 'touchstart', 'mousedown', 'click_active']; + +const rootEventTypes = hasPointerEvents + ? ['pointerup', 'pointermove', 'pointercancel', 'click', 'keyup', 'scroll'] + : [ + 'click', + 'keyup', + 'scroll', + 'mousemove', + 'touchmove', + 'touchcancel', + // Used as a 'cancel' signal for mouse interactions + 'dragstart', + 'mouseup', + 'touchend', + ]; + +function isFunction(obj): boolean { + return typeof obj === 'function'; +} + +function createPressEvent( + context: ReactDOMResponderContext, + type: PressEventType, + target: Element | Document, + pointerType: PointerType, + event: ?ReactDOMResponderEvent, + touchEvent: null | Touch, + defaultPrevented: boolean, + state: PressState, +): PressEvent { + const timeStamp = context.getTimeStamp(); + let clientX = null; + let clientY = null; + let pageX = null; + let pageY = null; + let screenX = null; + let screenY = null; + let altKey = false; + let ctrlKey = false; + let metaKey = false; + let shiftKey = false; + + if (event) { + const nativeEvent = (event.nativeEvent: any); + ({altKey, ctrlKey, metaKey, shiftKey} = nativeEvent); + // Only check for one property, checking for all of them is costly. We can assume + // if clientX exists, so do the rest. + let eventObject; + eventObject = (touchEvent: any) || (nativeEvent: any); + if (eventObject) { + ({clientX, clientY, pageX, pageY, screenX, screenY} = eventObject); + } + } + return { + altKey, + buttons: state.buttons, + clientX, + clientY, + ctrlKey, + defaultPrevented, + metaKey, + pageX, + pageY, + pointerType, + screenX, + screenY, + shiftKey, + target, + timeStamp, + type, + x: clientX, + y: clientY, + }; +} + +function dispatchEvent( + event: ?ReactDOMResponderEvent, + listener: any => void, + context: ReactDOMResponderContext, + state: PressState, + name: PressEventType, + eventPriority: EventPriority, +): void { + const target = ((state.pressTarget: any): Element | Document); + const pointerType = state.pointerType; + const defaultPrevented = + (event != null && event.nativeEvent.defaultPrevented === true) || + (name === 'press' && state.shouldPreventClick); + const touchEvent = state.touchEvent; + const syntheticEvent = createPressEvent( + context, + name, + target, + pointerType, + event, + touchEvent, + defaultPrevented, + state, + ); + context.dispatchEvent(syntheticEvent, listener, eventPriority); +} + +function dispatchPressChangeEvent( + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, +): void { + const onPressChange = props.onPressChange; + if (isFunction(onPressChange)) { + const bool = state.isActivePressed; + context.dispatchEvent(bool, onPressChange, DiscreteEvent); + } +} + +function dispatchPressStartEvents( + event: ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, +): void { + state.isPressed = true; + + if (!state.isActivePressStart) { + state.isActivePressStart = true; + const nativeEvent: any = event.nativeEvent; + const {clientX: x, clientY: y} = state.touchEvent || nativeEvent; + const wasActivePressed = state.isActivePressed; + state.isActivePressed = true; + if (x !== undefined && y !== undefined) { + state.activationPosition = {x, y}; + } + const onPressStart = props.onPressStart; + + if (isFunction(onPressStart)) { + dispatchEvent( + event, + onPressStart, + context, + state, + 'pressstart', + DiscreteEvent, + ); + } + if (!wasActivePressed) { + dispatchPressChangeEvent(context, props, state); + } + } +} + +function dispatchPressEndEvents( + event: ?ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, +): void { + state.isActivePressStart = false; + state.isPressed = false; + + if (state.isActivePressed) { + state.isActivePressed = false; + const onPressEnd = props.onPressEnd; + + if (isFunction(onPressEnd)) { + dispatchEvent( + event, + onPressEnd, + context, + state, + 'pressend', + DiscreteEvent, + ); + } + dispatchPressChangeEvent(context, props, state); + } + + state.responderRegionOnDeactivation = null; +} + +function dispatchCancel( + event: ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, +): void { + state.touchEvent = null; + if (state.isPressed) { + state.ignoreEmulatedMouseEvents = false; + dispatchPressEndEvents(event, context, props, state); + } + removeRootEventTypes(context, state); +} + +function isValidKeyboardEvent(nativeEvent: Object): boolean { + const {key, target} = nativeEvent; + const {tagName, isContentEditable} = target; + // Accessibility for keyboards. Space and Enter only. + // "Spacebar" is for IE 11 + return ( + (key === 'Enter' || key === ' ' || key === 'Spacebar') && + (tagName !== 'INPUT' && + tagName !== 'TEXTAREA' && + isContentEditable !== true) + ); +} + +// TODO: account for touch hit slop +function calculateResponderRegion( + context: ReactDOMResponderContext, + target: Element, + props: PressProps, +) { + const pressRetentionOffset = context.objectAssign( + {}, + DEFAULT_PRESS_RETENTION_OFFSET, + props.pressRetentionOffset, + ); + + let {left, right, bottom, top} = target.getBoundingClientRect(); + + if (pressRetentionOffset) { + if (pressRetentionOffset.bottom != null) { + bottom += pressRetentionOffset.bottom; + } + if (pressRetentionOffset.left != null) { + left -= pressRetentionOffset.left; + } + if (pressRetentionOffset.right != null) { + right += pressRetentionOffset.right; + } + if (pressRetentionOffset.top != null) { + top -= pressRetentionOffset.top; + } + } + + return { + bottom, + top, + left, + right, + }; +} + +function getTouchFromPressEvent(nativeEvent: TouchEvent): null | Touch { + const targetTouches = nativeEvent.targetTouches; + if (targetTouches.length > 0) { + return targetTouches[0]; + } + return null; +} + +function unmountResponder( + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, +): void { + if (state.isPressed) { + removeRootEventTypes(context, state); + dispatchPressEndEvents(null, context, props, state); + } +} + +function addRootEventTypes( + context: ReactDOMResponderContext, + state: PressState, +): void { + if (!state.addedRootEvents) { + state.addedRootEvents = true; + context.addRootEventTypes(rootEventTypes); + } +} + +function removeRootEventTypes( + context: ReactDOMResponderContext, + state: PressState, +): void { + if (state.addedRootEvents) { + state.addedRootEvents = false; + context.removeRootEventTypes(rootEventTypes); + } +} + +function getTouchById( + nativeEvent: TouchEvent, + pointerId: null | number, +): null | Touch { + const changedTouches = nativeEvent.changedTouches; + for (let i = 0; i < changedTouches.length; i++) { + const touch = changedTouches[i]; + if (touch.identifier === pointerId) { + return touch; + } + } + return null; +} + +function getTouchTarget(context: ReactDOMResponderContext, touchEvent: Touch) { + const doc = context.getActiveDocument(); + return doc.elementFromPoint(touchEvent.clientX, touchEvent.clientY); +} + +function updateIsPressWithinResponderRegion( + nativeEventOrTouchEvent: Event | Touch, + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, +): void { + // Calculate the responder region we use for deactivation if not + // already done during move event. + if (state.responderRegionOnDeactivation == null) { + state.responderRegionOnDeactivation = calculateResponderRegion( + context, + ((state.pressTarget: any): Element), + props, + ); + } + const {responderRegionOnActivation, responderRegionOnDeactivation} = state; + let left, top, right, bottom; + + if (responderRegionOnActivation != null) { + left = responderRegionOnActivation.left; + top = responderRegionOnActivation.top; + right = responderRegionOnActivation.right; + bottom = responderRegionOnActivation.bottom; + + if (responderRegionOnDeactivation != null) { + left = Math.min(left, responderRegionOnDeactivation.left); + top = Math.min(top, responderRegionOnDeactivation.top); + right = Math.max(right, responderRegionOnDeactivation.right); + bottom = Math.max(bottom, responderRegionOnDeactivation.bottom); + } + } + const {clientX: x, clientY: y} = (nativeEventOrTouchEvent: any); + + state.isPressWithinResponderRegion = + left != null && + right != null && + top != null && + bottom != null && + x !== null && + y !== null && + (x >= left && x <= right && y >= top && y <= bottom); +} + +// After some investigation work, screen reader virtual +// clicks (NVDA, Jaws, VoiceOver) do not have co-ords associated with the click +// event and "detail" is always 0 (where normal clicks are > 0) +function isScreenReaderVirtualClick(nativeEvent): boolean { + return ( + nativeEvent.detail === 0 && + nativeEvent.screenX === 0 && + nativeEvent.screenY === 0 && + nativeEvent.clientX === 0 && + nativeEvent.clientY === 0 + ); +} + +function targetIsDocument(target: null | Node): boolean { + // When target is null, it is the root + return target === null || target.nodeType === 9; +} + +const pressResponderImpl = { + targetEventTypes, + getInitialState(): PressState { + return { + activationPosition: null, + addedRootEvents: false, + buttons: 0, + isActivePressed: false, + isActivePressStart: false, + isPressed: false, + isPressWithinResponderRegion: true, + pointerType: '', + pressTarget: null, + responderRegionOnActivation: null, + responderRegionOnDeactivation: null, + ignoreEmulatedMouseEvents: false, + activePointerId: null, + shouldPreventClick: false, + touchEvent: null, + }; + }, + onEvent( + event: ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, + ): void { + const {pointerType, type} = event; + + if (props.disabled) { + removeRootEventTypes(context, state); + dispatchPressEndEvents(event, context, props, state); + state.ignoreEmulatedMouseEvents = false; + return; + } + const nativeEvent: any = event.nativeEvent; + const isPressed = state.isPressed; + + switch (type) { + // START + case 'pointerdown': + case 'keydown': + case 'mousedown': + case 'touchstart': { + if (!isPressed) { + const isTouchEvent = type === 'touchstart'; + const isPointerEvent = type === 'pointerdown'; + const isKeyboardEvent = pointerType === 'keyboard'; + const isMouseEvent = pointerType === 'mouse'; + + // Ignore emulated mouse events + if (type === 'mousedown' && state.ignoreEmulatedMouseEvents) { + return; + } + + state.shouldPreventClick = false; + if (isTouchEvent) { + state.ignoreEmulatedMouseEvents = true; + } else if (isKeyboardEvent) { + // Ignore unrelated key events + if (isValidKeyboardEvent(nativeEvent)) { + const { + altKey, + ctrlKey, + metaKey, + shiftKey, + } = (nativeEvent: MouseEvent); + if (nativeEvent.key === ' ') { + nativeEvent.preventDefault(); + } else if ( + props.preventDefault !== false && + !shiftKey && + !metaKey && + !ctrlKey && + !altKey + ) { + state.shouldPreventClick = true; + } + } else { + return; + } + } + + // We set these here, before the button check so we have this + // data around for handling of the context menu + state.pointerType = pointerType; + const pressTarget = (state.pressTarget = context.getResponderNode()); + if (isPointerEvent) { + state.activePointerId = nativeEvent.pointerId; + } else if (isTouchEvent) { + const touchEvent = getTouchFromPressEvent(nativeEvent); + if (touchEvent === null) { + return; + } + state.touchEvent = touchEvent; + state.activePointerId = touchEvent.identifier; + } + + // Ignore any device buttons except primary/middle and touch/pen contact. + // Additionally we ignore primary-button + ctrl-key with Macs as that + // acts like right-click and opens the contextmenu. + if ( + nativeEvent.buttons === 2 || + nativeEvent.buttons > 4 || + (isMac && isMouseEvent && nativeEvent.ctrlKey) + ) { + return; + } + // Exclude document targets + if (!targetIsDocument(pressTarget)) { + state.responderRegionOnActivation = calculateResponderRegion( + context, + ((pressTarget: any): Element), + props, + ); + } + state.responderRegionOnDeactivation = null; + state.isPressWithinResponderRegion = true; + state.buttons = nativeEvent.buttons; + dispatchPressStartEvents(event, context, props, state); + addRootEventTypes(context, state); + } else { + // Prevent spacebar press from scrolling the window + if (isValidKeyboardEvent(nativeEvent) && nativeEvent.key === ' ') { + nativeEvent.preventDefault(); + } + } + break; + } + + case 'click': { + if (state.shouldPreventClick) { + nativeEvent.preventDefault(); + } + const onPress = props.onPress; + + if (isFunction(onPress) && isScreenReaderVirtualClick(nativeEvent)) { + state.pointerType = 'keyboard'; + state.pressTarget = context.getResponderNode(); + const preventDefault = props.preventDefault; + + if (preventDefault !== false) { + nativeEvent.preventDefault(); + } + dispatchEvent(event, onPress, context, state, 'press', DiscreteEvent); + } + break; + } + } + }, + onRootEvent( + event: ReactDOMResponderEvent, + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, + ): void { + let {pointerType, target, type} = event; + + const nativeEvent: any = event.nativeEvent; + const isPressed = state.isPressed; + const activePointerId = state.activePointerId; + const previousPointerType = state.pointerType; + + switch (type) { + // MOVE + case 'pointermove': + case 'mousemove': + case 'touchmove': { + let touchEvent; + // Ignore emulated events (pointermove will dispatch touch and mouse events) + // Ignore pointermove events during a keyboard press. + if (previousPointerType !== pointerType) { + return; + } + if ( + type === 'pointermove' && + activePointerId !== nativeEvent.pointerId + ) { + return; + } else if (type === 'touchmove') { + touchEvent = getTouchById(nativeEvent, activePointerId); + if (touchEvent === null) { + return; + } + state.touchEvent = touchEvent; + } + const pressTarget = state.pressTarget; + + if (pressTarget !== null && !targetIsDocument(pressTarget)) { + if ( + pointerType === 'mouse' && + context.isTargetWithinNode(target, pressTarget) + ) { + state.isPressWithinResponderRegion = true; + } else { + // Calculate the responder region we use for deactivation, as the + // element dimensions may have changed since activation. + updateIsPressWithinResponderRegion( + touchEvent || nativeEvent, + context, + props, + state, + ); + } + } + + if (state.isPressWithinResponderRegion) { + if (isPressed) { + const onPressMove = props.onPressMove; + + if (isFunction(onPressMove)) { + dispatchEvent( + event, + onPressMove, + context, + state, + 'pressmove', + UserBlockingEvent, + ); + } + } else { + dispatchPressStartEvents(event, context, props, state); + } + } else { + dispatchPressEndEvents(event, context, props, state); + } + break; + } + + // END + case 'pointerup': + case 'keyup': + case 'mouseup': + case 'touchend': { + if (isPressed) { + const buttons = state.buttons; + let isKeyboardEvent = false; + let touchEvent; + if ( + type === 'pointerup' && + activePointerId !== nativeEvent.pointerId + ) { + return; + } else if (type === 'touchend') { + touchEvent = getTouchById(nativeEvent, activePointerId); + if (touchEvent === null) { + return; + } + state.touchEvent = touchEvent; + target = getTouchTarget(context, touchEvent); + } else if (type === 'keyup') { + // Ignore unrelated keyboard events + if (!isValidKeyboardEvent(nativeEvent)) { + return; + } + isKeyboardEvent = true; + removeRootEventTypes(context, state); + } else if (buttons === 4) { + // Remove the root events here as no 'click' event is dispatched when this 'button' is pressed. + removeRootEventTypes(context, state); + } + + // Determine whether to call preventDefault on subsequent native events. + if ( + context.isTargetWithinResponder(target) && + context.isTargetWithinHostComponent(target, 'a') + ) { + const { + altKey, + ctrlKey, + metaKey, + shiftKey, + } = (nativeEvent: MouseEvent); + // Check "open in new window/tab" and "open context menu" key modifiers + const preventDefault = props.preventDefault; + + if ( + preventDefault !== false && + !shiftKey && + !metaKey && + !ctrlKey && + !altKey + ) { + state.shouldPreventClick = true; + } + } + + const pressTarget = state.pressTarget; + dispatchPressEndEvents(event, context, props, state); + const onPress = props.onPress; + + if (pressTarget !== null && isFunction(onPress)) { + if ( + !isKeyboardEvent && + pressTarget !== null && + !targetIsDocument(pressTarget) + ) { + if ( + pointerType === 'mouse' && + context.isTargetWithinNode(target, pressTarget) + ) { + state.isPressWithinResponderRegion = true; + } else { + // If the event target isn't within the press target, check if we're still + // within the responder region. The region may have changed if the + // element's layout was modified after activation. + updateIsPressWithinResponderRegion( + touchEvent || nativeEvent, + context, + props, + state, + ); + } + } + + if (state.isPressWithinResponderRegion && buttons !== 4) { + dispatchEvent( + event, + onPress, + context, + state, + 'press', + DiscreteEvent, + ); + } + } + state.touchEvent = null; + } else if (type === 'mouseup') { + state.ignoreEmulatedMouseEvents = false; + } + break; + } + + case 'click': { + // "keyup" occurs after "click" + if (previousPointerType !== 'keyboard') { + removeRootEventTypes(context, state); + } + break; + } + + // CANCEL + case 'scroll': { + // We ignore incoming scroll events when using mouse events + if (previousPointerType === 'mouse') { + return; + } + const pressTarget = state.pressTarget; + const scrollTarget = nativeEvent.target; + const doc = context.getActiveDocument(); + // If the scroll target is the document or if the press target + // is inside the scroll target, then this a scroll that should + // trigger a cancel. + if ( + pressTarget !== null && + (scrollTarget === doc || + context.isTargetWithinNode(pressTarget, scrollTarget)) + ) { + dispatchCancel(event, context, props, state); + } + break; + } + case 'pointercancel': + case 'touchcancel': + case 'dragstart': { + dispatchCancel(event, context, props, state); + } + } + }, + onUnmount( + context: ReactDOMResponderContext, + props: PressProps, + state: PressState, + ) { + unmountResponder(context, props, state); + }, +}; + +export const PressResponder = React.unstable_createResponder( + 'Press', + pressResponderImpl, +); + +export function usePress( + props: PressProps, +): ReactEventResponderListener { + return React.unstable_useResponder(PressResponder, props); +} diff --git a/packages/react-ui/events/src/dom/Tap.js b/packages/react-ui/events/src/dom/Tap.js index 8e52e060f7d2b..df0019a607c76 100644 --- a/packages/react-ui/events/src/dom/Tap.js +++ b/packages/react-ui/events/src/dom/Tap.js @@ -36,26 +36,6 @@ type TapProps = $ReadOnly<{| onTapUpdate?: (e: TapEvent) => void, |}>; -type TapState = {| - activePointerId: null | number, - buttons: 0 | 1 | 4, - gestureState: TapGestureState, - ignoreEmulatedEvents: boolean, - initialPosition: {|x: number, y: number|}, - isActive: boolean, - pointerType: PointerType, - responderTarget: null | Element, - rootEvents: null | Array, - shouldPreventClick: boolean, -|}; - -type TapEventType = - | 'tap-cancel' - | 'tap-change' - | 'tap-end' - | 'tap-start' - | 'tap-update'; - type TapGestureState = {| altKey: boolean, buttons: 0 | 1 | 4, @@ -80,10 +60,31 @@ type TapGestureState = {| y: number, |}; -type TapEvent = $ReadOnly<{| +type TapState = {| + activePointerId: null | number, + buttons: 0 | 1 | 4, + gestureState: TapGestureState, + ignoreEmulatedEvents: boolean, + initialPosition: {|x: number, y: number|}, + isActive: boolean, + pointerType: PointerType, + responderTarget: null | Element, + rootEvents: null | Array, + shouldPreventClick: boolean, +|}; + +type TapEventType = + | 'tap:cancel' + | 'tap:change' + | 'tap:end' + | 'tap:start' + | 'tap:update'; + +type TapEvent = {| ...TapGestureState, + defaultPrevented: boolean, type: TapEventType, -|}>; +|}; /** * Native event dependencies @@ -380,6 +381,7 @@ function dispatchStart( const payload = context.objectAssign({}, state.gestureState, {type}); dispatchDiscreteEvent(context, payload, onTapStart); } + dispatchChange(context, props, state); } function dispatchChange( @@ -414,8 +416,13 @@ function dispatchEnd( ): void { const type = 'tap:end'; const onTapEnd = props.onTapEnd; + dispatchChange(context, props, state); if (onTapEnd != null) { - const payload = context.objectAssign({}, state.gestureState, {type}); + const defaultPrevented = state.shouldPreventClick === true; + const payload = context.objectAssign({}, state.gestureState, { + defaultPrevented, + type, + }); dispatchDiscreteEvent(context, payload, onTapEnd); } } @@ -427,6 +434,7 @@ function dispatchCancel( ): void { const type = 'tap:cancel'; const onTapCancel = props.onTapCancel; + dispatchChange(context, props, state); if (onTapCancel != null) { const payload = context.objectAssign({}, state.gestureState, {type}); dispatchDiscreteEvent(context, payload, onTapCancel); @@ -451,8 +459,8 @@ const responderImpl = { if (props.disabled) { removeRootEventTypes(context, state); if (state.isActive) { - dispatchCancel(context, props, state); state.isActive = false; + dispatchCancel(context, props, state); } return; } @@ -498,7 +506,6 @@ const responderImpl = { state.initialPosition.y = gestureState.y; dispatchStart(context, props, state); - dispatchChange(context, props, state); addRootEventTypes(rootEventTypes, context, state); if (!hasPointerEvents) { @@ -522,7 +529,7 @@ const responderImpl = { const hitTarget = getHitTarget(event, context, state); switch (eventType) { - // MOVE + // UPDATE case 'pointermove': case 'mousemove': case 'touchmove': { @@ -557,7 +564,6 @@ const responderImpl = { dispatchUpdate(context, props, state); } else { state.isActive = false; - dispatchChange(context, props, state); dispatchCancel(context, props, state); } } @@ -578,7 +584,6 @@ const responderImpl = { state.gestureState = createGestureState(context, props, state, event); state.isActive = false; - dispatchChange(context, props, state); if (context.isTargetWithinResponder(hitTarget)) { // Determine whether to call preventDefault on subsequent native events. if (hasModifierKey(event)) { @@ -606,7 +611,6 @@ const responderImpl = { if (state.isActive && isActivePointer(event, state)) { state.gestureState = createGestureState(context, props, state, event); state.isActive = false; - dispatchChange(context, props, state); dispatchCancel(context, props, state); } break; @@ -625,7 +629,6 @@ const responderImpl = { ) { state.gestureState = createGestureState(context, props, state, event); state.isActive = false; - dispatchChange(context, props, state); dispatchCancel(context, props, state); } break; @@ -647,8 +650,8 @@ const responderImpl = { ): void { removeRootEventTypes(context, state); if (state.isActive) { - dispatchCancel(context, props, state); state.isActive = false; + dispatchCancel(context, props, state); } }, }; diff --git a/packages/react-ui/events/src/dom/__tests__/Keyboard-test.internal.js b/packages/react-ui/events/src/dom/__tests__/Keyboard-test.internal.js index feea638e295a3..792d4a2088241 100644 --- a/packages/react-ui/events/src/dom/__tests__/Keyboard-test.internal.js +++ b/packages/react-ui/events/src/dom/__tests__/Keyboard-test.internal.js @@ -158,7 +158,7 @@ describe('Keyboard responder', () => { }); // e.g, "Enter" on link - test('keyboard click is between key events', () => { + test('click is between key events', () => { const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -168,7 +168,7 @@ describe('Keyboard responder', () => { expect.objectContaining({ altKey: false, ctrlKey: false, - defaultPrevented: false, + defaultPrevented: true, metaKey: false, pointerType: 'keyboard', shiftKey: false, @@ -180,7 +180,7 @@ describe('Keyboard responder', () => { }); // e.g., "Spacebar" on button - test('keyboard click is after key events', () => { + test('click is after key events', () => { const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); target.keyup({key: 'Enter'}); @@ -190,7 +190,27 @@ describe('Keyboard responder', () => { expect.objectContaining({ altKey: false, ctrlKey: false, - defaultPrevented: false, + defaultPrevented: true, + metaKey: false, + pointerType: 'keyboard', + shiftKey: false, + target: target.node, + timeStamp: expect.any(Number), + type: 'keyboard:click', + }), + ); + }); + + // e.g, generated by a screen-reader + test('click is orphan', () => { + const target = createEventTarget(ref.current); + target.virtualclick(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + altKey: false, + ctrlKey: false, + defaultPrevented: true, metaKey: false, pointerType: 'keyboard', shiftKey: false, @@ -326,6 +346,51 @@ describe('Keyboard responder', () => { }); }); + describe('preventClick', () => { + function render(props) { + const ref = React.createRef(); + const Component = () => { + const listener = useKeyboard(props); + return
; + }; + ReactDOM.render(, container); + return ref; + } + + test('prevents native click by default', () => { + const onClick = jest.fn(); + const preventDefault = jest.fn(); + const ref = render({onClick}); + + const target = createEventTarget(ref.current); + target.virtualclick({preventDefault}); + + expect(preventDefault).toBeCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + defaultPrevented: true, + }), + ); + }); + + test('allows native behaviour if false', () => { + const onClick = jest.fn(); + const preventDefault = jest.fn(); + const ref = render({onClick, preventClick: false}); + + const target = createEventTarget(ref.current); + target.virtualclick({preventDefault}); + expect(preventDefault).not.toBeCalled(); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledWith( + expect.objectContaining({ + defaultPrevented: false, + }), + ); + }); + }); + describe('preventKeys', () => { function render(props) { const ref = React.createRef(); @@ -347,9 +412,8 @@ describe('Keyboard responder', () => { target.keydown({key: 'Tab', preventDefault}); target.virtualclick({preventDefault: preventDefaultClick}); - expect(onKeyDown).toHaveBeenCalledTimes(1); expect(preventDefault).toBeCalled(); - expect(preventDefaultClick).toBeCalled(); + expect(onKeyDown).toHaveBeenCalledTimes(1); expect(onKeyDown).toHaveBeenCalledWith( expect.objectContaining({ defaultPrevented: true, @@ -362,19 +426,12 @@ describe('Keyboard responder', () => { test('key config matches (modifier keys)', () => { const onKeyDown = jest.fn(); const preventDefault = jest.fn(); - const preventDefaultClick = jest.fn(); const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]}); const target = createEventTarget(ref.current); target.keydown({key: 'Tab', preventDefault, shiftKey: true}); - target.virtualclick({ - preventDefault: preventDefaultClick, - shiftKey: true, - }); - - expect(onKeyDown).toHaveBeenCalledTimes(1); expect(preventDefault).toBeCalled(); - expect(preventDefaultClick).toBeCalled(); + expect(onKeyDown).toHaveBeenCalledTimes(1); expect(onKeyDown).toHaveBeenCalledWith( expect.objectContaining({ defaultPrevented: true, @@ -388,19 +445,12 @@ describe('Keyboard responder', () => { test('key config does not match (modifier keys)', () => { const onKeyDown = jest.fn(); const preventDefault = jest.fn(); - const preventDefaultClick = jest.fn(); const ref = render({onKeyDown, preventKeys: [['Tab', {shiftKey: true}]]}); const target = createEventTarget(ref.current); target.keydown({key: 'Tab', preventDefault, shiftKey: false}); - target.virtualclick({ - preventDefault: preventDefaultClick, - shiftKey: false, - }); - - expect(onKeyDown).toHaveBeenCalledTimes(1); expect(preventDefault).not.toBeCalled(); - expect(preventDefaultClick).not.toBeCalled(); + expect(onKeyDown).toHaveBeenCalledTimes(1); expect(onKeyDown).toHaveBeenCalledWith( expect.objectContaining({ defaultPrevented: false, diff --git a/packages/react-ui/events/src/dom/__tests__/MixedResponders-test-internal.js b/packages/react-ui/events/src/dom/__tests__/MixedResponders-test-internal.js index 26815678e1a43..7ae66a49a5f32 100644 --- a/packages/react-ui/events/src/dom/__tests__/MixedResponders-test-internal.js +++ b/packages/react-ui/events/src/dom/__tests__/MixedResponders-test-internal.js @@ -35,7 +35,7 @@ describe('mixing responders with the heritage event system', () => { }); it('should properly only flush sync once when the event systems are mixed', () => { - const usePress = require('react-ui/events/press').usePress; + const useTap = require('react-ui/events/tap').useTap; const ref = React.createRef(); let renderCounts = 0; @@ -43,12 +43,12 @@ describe('mixing responders with the heritage event system', () => { const [, updateCounter] = React.useState(0); renderCounts++; - function handlePress() { + function handleTap() { updateCounter(count => count + 1); } - const listener = usePress({ - onPress: handlePress, + const listener = useTap({ + onTapEnd: handleTap, }); return ( @@ -104,7 +104,7 @@ describe('mixing responders with the heritage event system', () => { }); it('should properly flush sync when the event systems are mixed with unstable_flushDiscreteUpdates', () => { - const usePress = require('react-ui/events/press').usePress; + const useTap = require('react-ui/events/tap').useTap; const ref = React.createRef(); let renderCounts = 0; @@ -112,12 +112,12 @@ describe('mixing responders with the heritage event system', () => { const [, updateCounter] = React.useState(0); renderCounts++; - function handlePress() { + function handleTap() { updateCounter(count => count + 1); } - const listener = usePress({ - onPress: handlePress, + const listener = useTap({ + onTapEnd: handleTap, }); return ( @@ -177,7 +177,7 @@ describe('mixing responders with the heritage event system', () => { 'event systems', async () => { const {useState} = React; - const usePress = require('react-ui/events/press').usePress; + const useTap = require('react-ui/events/tap').useTap; const button = React.createRef(); @@ -187,7 +187,7 @@ describe('mixing responders with the heritage event system', () => { const [pressesCount, updatePressesCount] = useState(0); const [clicksCount, updateClicksCount] = useState(0); - function handlePress() { + function handleTap() { // This dispatches a synchronous, discrete event in the legacy event // system. However, because it's nested inside the new event system, // its updates should not flush until the end of the outer handler. @@ -198,14 +198,14 @@ describe('mixing responders with the heritage event system', () => { updatePressesCount(pressesCount + 1); } - const listener = usePress({ - onPress: handlePress, + const tap = useTap({ + onTapEnd: handleTap, }); return (
- ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(divRef.current); - target.setBoundingClientRect({x: 0, y: 0, width: 0, height: 0}); - const innerTarget = createEventTarget(buttonRef.current); - innerTarget.pointerdown({pointerType: 'mouse'}); - innerTarget.pointerup({pointerType: 'mouse'}); - expect(onPress).toBeCalled(); - }); - it('is called once after virtual screen reader "click" event', () => { const target = createEventTarget(ref.current); const preventDefault = jest.fn(); @@ -488,386 +460,6 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); }); - describe.each(pointerTypesTable)('press with movement: %s', pointerType => { - let events, ref, outerRef; - - beforeEach(() => { - events = []; - ref = React.createRef(); - outerRef = React.createRef(); - const createEventHandler = msg => () => { - events.push(msg); - }; - const Component = () => { - const listener = usePress({ - onPress: createEventHandler('onPress'), - onPressChange: createEventHandler('onPressChange'), - onPressMove: createEventHandler('onPressMove'), - onPressStart: createEventHandler('onPressStart'), - onPressEnd: createEventHandler('onPressEnd'), - }); - return ( -
-
-
- ); - }; - ReactDOM.render(, container); - document.elementFromPoint = () => ref.current; - }); - - const rectMock = {width: 100, height: 100, x: 50, y: 50}; - const pressRectOffset = 20; - const coordinatesInside = { - x: rectMock.x - pressRectOffset, - y: rectMock.y - pressRectOffset, - }; - const coordinatesOutside = { - x: rectMock.x - pressRectOffset - 1, - y: rectMock.y - pressRectOffset - 1, - }; - - describe('within bounds of hit rect', () => { - /** ┌──────────────────┐ - * │ ┌────────────┐ │ - * │ │ VisualRect │ │ - * │ └────────────┘ │ - * │ HitRect X │ <= Move to X and release - * └──────────────────┘ - */ - it('"onPress*" events are called immediately', () => { - const target = createEventTarget(ref.current); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - target.pointermove({pointerType, ...coordinatesInside}); - target.pointerup({pointerType, ...coordinatesInside}); - expect(events).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressMove', - 'onPressEnd', - 'onPressChange', - 'onPress', - ]); - }); - - it('"onPress*" events are correctly called with target change', () => { - const target = createEventTarget(ref.current); - const outerTarget = createEventTarget(outerRef.current); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - target.pointermove({pointerType, ...coordinatesInside}); - // TODO: this sequence may differ in the future between PointerEvent and mouse fallback when - // use 'setPointerCapture'. - if (pointerType === 'touch') { - target.pointermove({pointerType, ...coordinatesOutside}); - } else { - outerTarget.pointermove({pointerType, ...coordinatesOutside}); - } - target.pointermove({pointerType, ...coordinatesInside}); - target.pointerup({pointerType, ...coordinatesInside}); - - expect(events.filter(removePressMoveStrings)).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressEnd', - 'onPressChange', - 'onPressStart', - 'onPressChange', - 'onPressEnd', - 'onPressChange', - 'onPress', - ]); - }); - - it('press retention offset can be configured', () => { - let localEvents = []; - const localRef = React.createRef(); - const createEventHandler = msg => () => { - localEvents.push(msg); - }; - const pressRetentionOffset = {top: 40, bottom: 40, left: 40, right: 40}; - - const Component = () => { - const listener = usePress({ - onPress: createEventHandler('onPress'), - onPressChange: createEventHandler('onPressChange'), - onPressMove: createEventHandler('onPressMove'), - onPressStart: createEventHandler('onPressStart'), - onPressEnd: createEventHandler('onPressEnd'), - pressRetentionOffset, - }); - return
; - }; - ReactDOM.render(, container); - - const target = createEventTarget(localRef.current); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - target.pointermove({ - pointerType, - x: rectMock.x, - y: rectMock.y, - }); - target.pointerup({pointerType, ...coordinatesInside}); - expect(localEvents).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressMove', - 'onPressEnd', - 'onPressChange', - 'onPress', - ]); - }); - - it('responder region accounts for decrease in element dimensions', () => { - const target = createEventTarget(ref.current); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - // emulate smaller dimensions change on activation - target.setBoundingClientRect({width: 80, height: 80, y: 60, x: 60}); - const coordinates = {x: rectMock.x, y: rectMock.y}; - // move to an area within the pre-activation region - target.pointermove({pointerType, ...coordinates}); - target.pointerup({pointerType, ...coordinates}); - expect(events).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressMove', - 'onPressEnd', - 'onPressChange', - 'onPress', - ]); - }); - - it('responder region accounts for increase in element dimensions', () => { - const target = createEventTarget(ref.current); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - // emulate larger dimensions change on activation - target.setBoundingClientRect({width: 200, height: 200, y: 0, x: 0}); - const coordinates = {x: rectMock.x - 50, y: rectMock.y - 50}; - // move to an area within the post-activation region - target.pointermove({pointerType, ...coordinates}); - target.pointerup({pointerType, ...coordinates}); - expect(events).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressMove', - 'onPressEnd', - 'onPressChange', - 'onPress', - ]); - }); - }); - - describe('beyond bounds of hit rect', () => { - /** ┌──────────────────┐ - * │ ┌────────────┐ │ - * │ │ VisualRect │ │ - * │ └────────────┘ │ - * │ HitRect │ - * └──────────────────┘ - * X <= Move to X and release - */ - it('"onPress" is not called on release', () => { - const target = createEventTarget(ref.current); - const targetContainer = createEventTarget(container); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - target.pointermove({pointerType, ...coordinatesInside}); - if (pointerType === 'mouse') { - // TODO: use setPointerCapture so this is only true for fallback mouse events. - targetContainer.pointermove({pointerType, ...coordinatesOutside}); - targetContainer.pointerup({pointerType, ...coordinatesOutside}); - } else { - target.pointermove({pointerType, ...coordinatesOutside}); - target.pointerup({pointerType, ...coordinatesOutside}); - } - expect(events.filter(removePressMoveStrings)).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressEnd', - 'onPressChange', - ]); - }); - }); - - it('"onPress" is called on re-entry to hit rect', () => { - const target = createEventTarget(ref.current); - const targetContainer = createEventTarget(container); - target.setBoundingClientRect(rectMock); - target.pointerdown({pointerType}); - target.pointermove({pointerType, ...coordinatesInside}); - if (pointerType === 'mouse') { - // TODO: use setPointerCapture so this is only true for fallback mouse events. - targetContainer.pointermove({pointerType, ...coordinatesOutside}); - } else { - target.pointermove({pointerType, ...coordinatesOutside}); - } - target.pointermove({pointerType, ...coordinatesInside}); - target.pointerup({pointerType, ...coordinatesInside}); - - expect(events).toEqual([ - 'onPressStart', - 'onPressChange', - 'onPressMove', - 'onPressEnd', - 'onPressChange', - 'onPressStart', - 'onPressChange', - 'onPressEnd', - 'onPressChange', - 'onPress', - ]); - }); - }); - - describe('nested responders', () => { - if (hasPointerEvents) { - it('dispatch events in the correct order', () => { - const events = []; - const ref = React.createRef(); - const createEventHandler = msg => () => { - events.push(msg); - }; - - const Inner = () => { - const listener = usePress({ - onPress: createEventHandler('inner: onPress'), - onPressChange: createEventHandler('inner: onPressChange'), - onPressMove: createEventHandler('inner: onPressMove'), - onPressStart: createEventHandler('inner: onPressStart'), - onPressEnd: createEventHandler('inner: onPressEnd'), - }); - return ( -
- ); - }; - - const Outer = () => { - const listener = usePress({ - onPress: createEventHandler('outer: onPress'), - onPressChange: createEventHandler('outer: onPressChange'), - onPressMove: createEventHandler('outer: onPressMove'), - onPressStart: createEventHandler('outer: onPressStart'), - onPressEnd: createEventHandler('outer: onPressEnd'), - }); - return ( -
- -
- ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); - target.pointerdown(); - target.pointerup(); - expect(events).toEqual([ - 'inner: onPressStart', - 'inner: onPressChange', - 'pointerdown', - 'inner: onPressEnd', - 'inner: onPressChange', - 'inner: onPress', - 'pointerup', - ]); - }); - } - - describe('correctly not propagate', () => { - it('for onPress', () => { - const ref = React.createRef(); - const onPress = jest.fn(); - - const Inner = () => { - const listener = usePress({onPress}); - return
; - }; - - const Outer = () => { - const listener = usePress({onPress}); - return ( -
- -
- ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); - target.pointerdown(); - target.pointerup(); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('for onPressStart/onPressEnd', () => { - const ref = React.createRef(); - const onPressStart = jest.fn(); - const onPressEnd = jest.fn(); - - const Inner = () => { - const listener = usePress({onPressStart, onPressEnd}); - return
; - }; - - const Outer = () => { - const listener = usePress({onPressStart, onPressEnd}); - return ( -
- -
- ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - target.pointerdown(); - expect(onPressStart).toHaveBeenCalledTimes(1); - expect(onPressEnd).toHaveBeenCalledTimes(0); - target.pointerup(); - expect(onPressStart).toHaveBeenCalledTimes(1); - expect(onPressEnd).toHaveBeenCalledTimes(1); - }); - - it('for onPressChange', () => { - const ref = React.createRef(); - const onPressChange = jest.fn(); - - const Inner = () => { - const listener = usePress({onPressChange}); - return
; - }; - - const Outer = () => { - const listener = usePress({onPressChange}); - return ( -
- -
- ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - target.pointerdown(); - expect(onPressChange).toHaveBeenCalledTimes(1); - target.pointerup(); - expect(onPressChange).toHaveBeenCalledTimes(2); - }); - }); - }); - describe('link components', () => { it('prevents native behavior by default', () => { const onPress = jest.fn(); @@ -891,7 +483,8 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { it('prevents native behaviour for keyboard events by default', () => { const onPress = jest.fn(); - const preventDefault = jest.fn(); + const preventDefaultClick = jest.fn(); + const preventDefaultKeyDown = jest.fn(); const ref = React.createRef(); const Component = () => { @@ -901,10 +494,12 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { ReactDOM.render(, container); const target = createEventTarget(ref.current); - target.keydown({key: 'Enter'}); - target.click({preventDefault}); + target.keydown({key: 'Enter', preventDefault: preventDefaultKeyDown}); + target.virtualclick({preventDefault: preventDefaultClick}); target.keyup({key: 'Enter'}); - expect(preventDefault).toBeCalled(); + expect(preventDefaultKeyDown).toBeCalled(); + expect(preventDefaultClick).toBeCalled(); + expect(onPress).toHaveBeenCalledTimes(1); expect(onPress).toHaveBeenCalledWith( expect.objectContaining({defaultPrevented: true}), ); @@ -1010,99 +605,16 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { const target = createEventTarget(ref.current); target.keydown({key: 'Enter'}); - target.click({preventDefault}); + target.virtualclick({preventDefault}); target.keyup({key: 'Enter'}); expect(preventDefault).not.toBeCalled(); + expect(onPress).toHaveBeenCalledTimes(1); expect(onPress).toHaveBeenCalledWith( expect.objectContaining({defaultPrevented: false}), ); }); }); - describe('responder cancellation', () => { - it.each(pointerTypesTable)('ends on pointer cancel', pointerType => { - const onPressEnd = jest.fn(); - const ref = React.createRef(); - - const Component = () => { - const listener = usePress({onPressEnd}); - return ; - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - target.pointerdown({pointerType}); - target.pointercancel({pointerType}); - expect(onPressEnd).toHaveBeenCalledTimes(1); - }); - }); - - it('does end on "scroll" to document (not mouse)', () => { - const onPressEnd = jest.fn(); - const ref = React.createRef(); - - const Component = () => { - const listener = usePress({onPressEnd}); - return ; - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - const targetDocument = createEventTarget(document); - target.pointerdown({pointerType: 'touch'}); - targetDocument.scroll(); - expect(onPressEnd).toHaveBeenCalledTimes(1); - }); - - it('does end on "scroll" to a parent container (not mouse)', () => { - const onPressEnd = jest.fn(); - const ref = React.createRef(); - const containerRef = React.createRef(); - - const Component = () => { - const listener = usePress({onPressEnd}); - return ( - - ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - const targetContainer = createEventTarget(containerRef.current); - target.pointerdown({pointerType: 'touch'}); - targetContainer.scroll(); - expect(onPressEnd).toHaveBeenCalledTimes(1); - }); - - it('does not end on "scroll" to an element outside', () => { - const onPressEnd = jest.fn(); - const ref = React.createRef(); - const outsideRef = React.createRef(); - - const Component = () => { - const listener = usePress({onPressEnd}); - return ( - - ); - }; - ReactDOM.render(, container); - - const target = createEventTarget(ref.current); - const targetOutside = createEventTarget(outsideRef.current); - target.pointerdown(); - targetOutside.scroll(); - expect(onPressEnd).not.toBeCalled(); - }); - - it('expect displayName to show up for event component', () => { - expect(PressResponder.displayName).toBe('Press'); - }); - it('should not trigger an invariant in addRootEventTypes()', () => { const ref = React.createRef(); diff --git a/packages/react-ui/events/src/dom/__tests__/PressLegacy-test.internal.js b/packages/react-ui/events/src/dom/__tests__/PressLegacy-test.internal.js new file mode 100644 index 0000000000000..0f90ca2b56a86 --- /dev/null +++ b/packages/react-ui/events/src/dom/__tests__/PressLegacy-test.internal.js @@ -0,0 +1,1121 @@ +/** + * 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'; + +import { + buttonsType, + createEventTarget, + setPointerEvent, +} from '../testing-library'; + +let React; +let ReactFeatureFlags; +let ReactDOM; +let PressResponder; +let usePress; + +function initializeModules(hasPointerEvents) { + jest.resetModules(); + setPointerEvent(hasPointerEvents); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableFlareAPI = true; + React = require('react'); + ReactDOM = require('react-dom'); + PressResponder = require('react-ui/events/press-legacy').PressResponder; + usePress = require('react-ui/events/press-legacy').usePress; +} + +function removePressMoveStrings(eventString) { + if (eventString === 'onPressMove') { + return false; + } + return true; +} + +const forcePointerEvents = true; +const environmentTable = [[forcePointerEvents], [!forcePointerEvents]]; + +const pointerTypesTable = [['mouse'], ['touch']]; + +describe.each(environmentTable)('Press responder', hasPointerEvents => { + let container; + + beforeEach(() => { + initializeModules(hasPointerEvents); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + ReactDOM.render(null, container); + document.body.removeChild(container); + container = null; + }); + + describe('disabled', () => { + let onPressStart, onPress, onPressEnd, ref; + + beforeEach(() => { + onPressStart = jest.fn(); + onPress = jest.fn(); + onPressEnd = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = usePress({ + disabled: true, + onPressStart, + onPress, + onPressEnd, + }); + return
; + }; + ReactDOM.render(, container); + document.elementFromPoint = () => ref.current; + }); + + it('does not call callbacks', () => { + const target = createEventTarget(ref.current); + target.pointerdown(); + target.pointerup(); + expect(onPressStart).not.toBeCalled(); + expect(onPress).not.toBeCalled(); + expect(onPressEnd).not.toBeCalled(); + }); + }); + + describe('onPressStart', () => { + let onPressStart, ref; + + beforeEach(() => { + onPressStart = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = usePress({ + onPressStart, + }); + return
; + }; + ReactDOM.render(, container); + document.elementFromPoint = () => ref.current; + }); + + it.each(pointerTypesTable)( + 'is called after pointer down: %s', + pointerType => { + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType, type: 'pressstart'}), + ); + }, + ); + + it('is called after middle-button pointer down', () => { + const target = createEventTarget(ref.current); + target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: buttonsType.middle, + pointerType: 'mouse', + type: 'pressstart', + }), + ); + }); + + it('is not called after pointer move following middle-button press', () => { + const node = ref.current; + const target = createEventTarget(node); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse'}); + target.pointerhover({x: 110, y: 110}); + target.pointerhover({x: 50, y: 50}); + expect(onPressStart).toHaveBeenCalledTimes(1); + }); + + it('ignores any events not caused by primary/middle-click or touch/pen contact', () => { + const target = createEventTarget(ref.current); + target.pointerdown({buttons: buttonsType.secondary}); + target.pointerup({buttons: buttonsType.secondary}); + target.pointerdown({buttons: buttonsType.eraser}); + target.pointerup({buttons: buttonsType.eraser}); + expect(onPressStart).toHaveBeenCalledTimes(0); + }); + + it('is called once after "keydown" events for Enter', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.keydown({key: 'Enter'}); + target.keydown({key: 'Enter'}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressstart'}), + ); + }); + + it('is called once after "keydown" events for Spacebar', () => { + const target = createEventTarget(ref.current); + const preventDefault = jest.fn(); + target.keydown({key: ' ', preventDefault}); + expect(preventDefault).toBeCalled(); + target.keydown({key: ' ', preventDefault}); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressStart).toHaveBeenCalledWith( + expect.objectContaining({ + pointerType: 'keyboard', + type: 'pressstart', + }), + ); + }); + + it('is not called after "keydown" for other keys', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'a'}); + expect(onPressStart).not.toBeCalled(); + }); + }); + + describe('onPressEnd', () => { + let onPressEnd, ref; + + beforeEach(() => { + onPressEnd = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = usePress({ + onPressEnd, + }); + return
; + }; + ReactDOM.render(, container); + document.elementFromPoint = () => ref.current; + }); + + it.each(pointerTypesTable)( + 'is called after pointer up: %s', + pointerType => { + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + target.pointerup({pointerType}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType, type: 'pressend'}), + ); + }, + ); + + it('is called after middle-button pointer up', () => { + const target = createEventTarget(ref.current); + target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({ + buttons: buttonsType.middle, + pointerType: 'mouse', + type: 'pressend', + }), + ); + }); + + it('is called after "keyup" event for Enter', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + // click occurs before keyup + target.click(); + target.keyup({key: 'Enter'}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressend'}), + ); + }); + + it('is called after "keyup" event for Spacebar', () => { + const target = createEventTarget(ref.current); + target.keydown({key: ' '}); + target.keyup({key: ' '}); + expect(onPressEnd).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'pressend'}), + ); + }); + + it('is not called after "keyup" event for other keys', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.keyup({key: 'a'}); + expect(onPressEnd).not.toBeCalled(); + }); + + it('is called with keyboard modifiers', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.keyup({ + key: 'Enter', + metaKey: true, + ctrlKey: true, + altKey: true, + shiftKey: true, + }); + expect(onPressEnd).toHaveBeenCalledWith( + expect.objectContaining({ + pointerType: 'keyboard', + type: 'pressend', + metaKey: true, + ctrlKey: true, + altKey: true, + shiftKey: true, + }), + ); + }); + }); + + describe('onPressChange', () => { + let onPressChange, ref; + + beforeEach(() => { + onPressChange = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = usePress({ + onPressChange, + }); + return
; + }; + ReactDOM.render(, container); + document.elementFromPoint = () => ref.current; + }); + + it.each(pointerTypesTable)( + 'is called after pointer down and up: %s', + pointerType => { + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + target.pointerup({pointerType}); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }, + ); + + it('is called after valid "keydown" and "keyup" events', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + expect(onPressChange).toHaveBeenCalledTimes(1); + expect(onPressChange).toHaveBeenCalledWith(true); + target.keyup({key: 'Enter'}); + expect(onPressChange).toHaveBeenCalledTimes(2); + expect(onPressChange).toHaveBeenCalledWith(false); + }); + }); + + describe('onPress', () => { + let onPress, ref; + + beforeEach(() => { + onPress = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = usePress({ + onPress, + }); + return
; + }; + ReactDOM.render(, container); + ref.current.getBoundingClientRect = () => ({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }); + document.elementFromPoint = () => ref.current; + }); + + it.each(pointerTypesTable)( + 'is called after pointer up: %s', + pointerType => { + const target = createEventTarget(ref.current); + target.pointerdown({pointerType}); + target.pointerup({pointerType, x: 10, y: 10}); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType, type: 'press'}), + ); + }, + ); + + it('is not called after middle-button press', () => { + const target = createEventTarget(ref.current); + target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'}); + target.pointerup({pointerType: 'mouse'}); + expect(onPress).not.toHaveBeenCalled(); + }); + + it('is called after valid "keyup" event', () => { + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.keyup({key: 'Enter'}); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'press'}), + ); + }); + + it('is not called after invalid "keyup" event', () => { + const inputRef = React.createRef(); + const Component = () => { + const listener = usePress({onPress}); + return ; + }; + ReactDOM.render(, container); + const target = createEventTarget(inputRef.current); + target.keydown({key: 'Enter'}); + target.keyup({key: 'Enter'}); + target.keydown({key: ' '}); + target.keyup({key: ' '}); + expect(onPress).not.toBeCalled(); + }); + + it('is called with modifier keys', () => { + const target = createEventTarget(ref.current); + target.pointerdown({metaKey: true, pointerType: 'mouse'}); + target.pointerup({metaKey: true, pointerType: 'mouse'}); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({ + pointerType: 'mouse', + type: 'press', + metaKey: true, + }), + ); + }); + + it('is called if target rect is not right but the target is (for mouse events)', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + + const Component = () => { + const listener = usePress({onPress}); + return ( +
+
+ ); + }; + ReactDOM.render(, container); + + const target = createEventTarget(divRef.current); + target.setBoundingClientRect({x: 0, y: 0, width: 0, height: 0}); + const innerTarget = createEventTarget(buttonRef.current); + innerTarget.pointerdown({pointerType: 'mouse'}); + innerTarget.pointerup({pointerType: 'mouse'}); + expect(onPress).toBeCalled(); + }); + + it('is called once after virtual screen reader "click" event', () => { + const target = createEventTarget(ref.current); + const preventDefault = jest.fn(); + target.virtualclick({preventDefault}); + expect(preventDefault).toBeCalled(); + expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({ + pointerType: 'keyboard', + type: 'press', + }), + ); + }); + }); + + describe('onPressMove', () => { + let onPressMove, ref; + + beforeEach(() => { + onPressMove = jest.fn(); + ref = React.createRef(); + const Component = () => { + const listener = usePress({ + onPressMove, + }); + return
; + }; + ReactDOM.render(, container); + ref.current.getBoundingClientRect = () => ({ + top: 0, + left: 0, + bottom: 100, + right: 100, + }); + document.elementFromPoint = () => ref.current; + }); + + it.each(pointerTypesTable)( + 'is called after pointer move: %s', + pointerType => { + const node = ref.current; + const target = createEventTarget(node); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown({pointerType}); + target.pointermove({pointerType, x: 10, y: 10}); + target.pointermove({pointerType, x: 20, y: 20}); + expect(onPressMove).toHaveBeenCalledTimes(2); + expect(onPressMove).toHaveBeenCalledWith( + expect.objectContaining({pointerType, type: 'pressmove'}), + ); + }, + ); + + it('is not called if pointer move occurs during keyboard press', () => { + const target = createEventTarget(ref.current); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.keydown({key: 'Enter'}); + target.pointermove({ + buttons: buttonsType.none, + pointerType: 'mouse', + x: 10, + y: 10, + }); + expect(onPressMove).not.toBeCalled(); + }); + }); + + describe.each(pointerTypesTable)('press with movement: %s', pointerType => { + let events, ref, outerRef; + + beforeEach(() => { + events = []; + ref = React.createRef(); + outerRef = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + const Component = () => { + const listener = usePress({ + onPress: createEventHandler('onPress'), + onPressChange: createEventHandler('onPressChange'), + onPressMove: createEventHandler('onPressMove'), + onPressStart: createEventHandler('onPressStart'), + onPressEnd: createEventHandler('onPressEnd'), + }); + return ( +
+
+
+ ); + }; + ReactDOM.render(, container); + document.elementFromPoint = () => ref.current; + }); + + const rectMock = {width: 100, height: 100, x: 50, y: 50}; + const pressRectOffset = 20; + const coordinatesInside = { + x: rectMock.x - pressRectOffset, + y: rectMock.y - pressRectOffset, + }; + const coordinatesOutside = { + x: rectMock.x - pressRectOffset - 1, + y: rectMock.y - pressRectOffset - 1, + }; + + describe('within bounds of hit rect', () => { + /** ┌──────────────────┐ + * │ ┌────────────┐ │ + * │ │ VisualRect │ │ + * │ └────────────┘ │ + * │ HitRect X │ <= Move to X and release + * └──────────────────┘ + */ + it('"onPress*" events are called immediately', () => { + const target = createEventTarget(ref.current); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + target.pointermove({pointerType, ...coordinatesInside}); + target.pointerup({pointerType, ...coordinatesInside}); + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + + it('"onPress*" events are correctly called with target change', () => { + const target = createEventTarget(ref.current); + const outerTarget = createEventTarget(outerRef.current); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + target.pointermove({pointerType, ...coordinatesInside}); + // TODO: this sequence may differ in the future between PointerEvent and mouse fallback when + // use 'setPointerCapture'. + if (pointerType === 'touch') { + target.pointermove({pointerType, ...coordinatesOutside}); + } else { + outerTarget.pointermove({pointerType, ...coordinatesOutside}); + } + target.pointermove({pointerType, ...coordinatesInside}); + target.pointerup({pointerType, ...coordinatesInside}); + + expect(events.filter(removePressMoveStrings)).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressEnd', + 'onPressChange', + 'onPressStart', + 'onPressChange', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + + it('press retention offset can be configured', () => { + let localEvents = []; + const localRef = React.createRef(); + const createEventHandler = msg => () => { + localEvents.push(msg); + }; + const pressRetentionOffset = {top: 40, bottom: 40, left: 40, right: 40}; + + const Component = () => { + const listener = usePress({ + onPress: createEventHandler('onPress'), + onPressChange: createEventHandler('onPressChange'), + onPressMove: createEventHandler('onPressMove'), + onPressStart: createEventHandler('onPressStart'), + onPressEnd: createEventHandler('onPressEnd'), + pressRetentionOffset, + }); + return
; + }; + ReactDOM.render(, container); + + const target = createEventTarget(localRef.current); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + target.pointermove({ + pointerType, + x: rectMock.x, + y: rectMock.y, + }); + target.pointerup({pointerType, ...coordinatesInside}); + expect(localEvents).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + + it('responder region accounts for decrease in element dimensions', () => { + const target = createEventTarget(ref.current); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + // emulate smaller dimensions change on activation + target.setBoundingClientRect({width: 80, height: 80, y: 60, x: 60}); + const coordinates = {x: rectMock.x, y: rectMock.y}; + // move to an area within the pre-activation region + target.pointermove({pointerType, ...coordinates}); + target.pointerup({pointerType, ...coordinates}); + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + + it('responder region accounts for increase in element dimensions', () => { + const target = createEventTarget(ref.current); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + // emulate larger dimensions change on activation + target.setBoundingClientRect({width: 200, height: 200, y: 0, x: 0}); + const coordinates = {x: rectMock.x - 50, y: rectMock.y - 50}; + // move to an area within the post-activation region + target.pointermove({pointerType, ...coordinates}); + target.pointerup({pointerType, ...coordinates}); + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + }); + + describe('beyond bounds of hit rect', () => { + /** ┌──────────────────┐ + * │ ┌────────────┐ │ + * │ │ VisualRect │ │ + * │ └────────────┘ │ + * │ HitRect │ + * └──────────────────┘ + * X <= Move to X and release + */ + it('"onPress" is not called on release', () => { + const target = createEventTarget(ref.current); + const targetContainer = createEventTarget(container); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + target.pointermove({pointerType, ...coordinatesInside}); + if (pointerType === 'mouse') { + // TODO: use setPointerCapture so this is only true for fallback mouse events. + targetContainer.pointermove({pointerType, ...coordinatesOutside}); + targetContainer.pointerup({pointerType, ...coordinatesOutside}); + } else { + target.pointermove({pointerType, ...coordinatesOutside}); + target.pointerup({pointerType, ...coordinatesOutside}); + } + expect(events.filter(removePressMoveStrings)).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressEnd', + 'onPressChange', + ]); + }); + }); + + it('"onPress" is called on re-entry to hit rect', () => { + const target = createEventTarget(ref.current); + const targetContainer = createEventTarget(container); + target.setBoundingClientRect(rectMock); + target.pointerdown({pointerType}); + target.pointermove({pointerType, ...coordinatesInside}); + if (pointerType === 'mouse') { + // TODO: use setPointerCapture so this is only true for fallback mouse events. + targetContainer.pointermove({pointerType, ...coordinatesOutside}); + } else { + target.pointermove({pointerType, ...coordinatesOutside}); + } + target.pointermove({pointerType, ...coordinatesInside}); + target.pointerup({pointerType, ...coordinatesInside}); + + expect(events).toEqual([ + 'onPressStart', + 'onPressChange', + 'onPressMove', + 'onPressEnd', + 'onPressChange', + 'onPressStart', + 'onPressChange', + 'onPressEnd', + 'onPressChange', + 'onPress', + ]); + }); + }); + + describe('nested responders', () => { + if (hasPointerEvents) { + it('dispatch events in the correct order', () => { + const events = []; + const ref = React.createRef(); + const createEventHandler = msg => () => { + events.push(msg); + }; + + const Inner = () => { + const listener = usePress({ + onPress: createEventHandler('inner: onPress'), + onPressChange: createEventHandler('inner: onPressChange'), + onPressMove: createEventHandler('inner: onPressMove'), + onPressStart: createEventHandler('inner: onPressStart'), + onPressEnd: createEventHandler('inner: onPressEnd'), + }); + return ( +
+ ); + }; + + const Outer = () => { + const listener = usePress({ + onPress: createEventHandler('outer: onPress'), + onPressChange: createEventHandler('outer: onPressChange'), + onPressMove: createEventHandler('outer: onPressMove'), + onPressStart: createEventHandler('outer: onPressStart'), + onPressEnd: createEventHandler('outer: onPressEnd'), + }); + return ( +
+ +
+ ); + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown(); + target.pointerup(); + expect(events).toEqual([ + 'inner: onPressStart', + 'inner: onPressChange', + 'pointerdown', + 'inner: onPressEnd', + 'inner: onPressChange', + 'inner: onPress', + 'pointerup', + ]); + }); + } + + describe('correctly not propagate', () => { + it('for onPress', () => { + const ref = React.createRef(); + const onPress = jest.fn(); + + const Inner = () => { + const listener = usePress({onPress}); + return
; + }; + + const Outer = () => { + const listener = usePress({onPress}); + return ( +
+ +
+ ); + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.setBoundingClientRect({x: 0, y: 0, width: 100, height: 100}); + target.pointerdown(); + target.pointerup(); + expect(onPress).toHaveBeenCalledTimes(1); + }); + + it('for onPressStart/onPressEnd', () => { + const ref = React.createRef(); + const onPressStart = jest.fn(); + const onPressEnd = jest.fn(); + + const Inner = () => { + const listener = usePress({onPressStart, onPressEnd}); + return
; + }; + + const Outer = () => { + const listener = usePress({onPressStart, onPressEnd}); + return ( +
+ +
+ ); + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.pointerdown(); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledTimes(0); + target.pointerup(); + expect(onPressStart).toHaveBeenCalledTimes(1); + expect(onPressEnd).toHaveBeenCalledTimes(1); + }); + + it('for onPressChange', () => { + const ref = React.createRef(); + const onPressChange = jest.fn(); + + const Inner = () => { + const listener = usePress({onPressChange}); + return
; + }; + + const Outer = () => { + const listener = usePress({onPressChange}); + return ( +
+ +
+ ); + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.pointerdown(); + expect(onPressChange).toHaveBeenCalledTimes(1); + target.pointerup(); + expect(onPressChange).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe('link components', () => { + it('prevents native behavior by default', () => { + const onPress = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + + const Component = () => { + const listener = usePress({onPress}); + return ; + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.pointerdown(); + target.pointerup({preventDefault}); + expect(preventDefault).toBeCalled(); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({defaultPrevented: true}), + ); + }); + + it('prevents native behaviour for keyboard events by default', () => { + const onPress = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + + const Component = () => { + const listener = usePress({onPress}); + return ; + }; + ReactDOM.render(, container); + + const target = createEventTarget(ref.current); + target.keydown({key: 'Enter'}); + target.click({preventDefault}); + target.keyup({key: 'Enter'}); + expect(preventDefault).toBeCalled(); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({defaultPrevented: true}), + ); + }); + + it('deeply prevents native behaviour by default', () => { + const onPress = jest.fn(); + const preventDefault = jest.fn(); + const buttonRef = React.createRef(); + + const Component = () => { + const listener = usePress({onPress}); + return ( + +