diff --git a/packages/react-dom/src/shared/ReactDOMTypes.js b/packages/react-dom/src/shared/ReactDOMTypes.js index 77930f789d..18f2cb7dc6 100644 --- a/packages/react-dom/src/shared/ReactDOMTypes.js +++ b/packages/react-dom/src/shared/ReactDOMTypes.js @@ -16,7 +16,7 @@ import type { } from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; -type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | TouchEvent; +type AnyNativeEvent = Event | KeyboardEvent | MouseEvent | Touch; export type PointerType = | '' diff --git a/packages/react-interactions/events/src/dom/PressLegacy.js b/packages/react-interactions/events/src/dom/PressLegacy.js index f5ea903a37..0b3756cb48 100644 --- a/packages/react-interactions/events/src/dom/PressLegacy.js +++ b/packages/react-interactions/events/src/dom/PressLegacy.js @@ -64,6 +64,7 @@ type PressState = { |}>, ignoreEmulatedMouseEvents: boolean, activePointerId: null | number, + shouldPreventClick: boolean, touchEvent: null | Touch, ... }; @@ -198,6 +199,7 @@ function createPressEvent( x: clientX, y: clientY, preventDefault() { + state.shouldPreventClick = true; if (nativeEvent) { pressEvent.defaultPrevented = true; nativeEvent.preventDefault(); @@ -227,7 +229,8 @@ function dispatchEvent( const target = ((state.pressTarget: any): Element | Document); const pointerType = state.pointerType; const defaultPrevented = - event != null && event.nativeEvent.defaultPrevented === true; + (event != null && event.nativeEvent.defaultPrevented === true) || + (name === 'press' && state.shouldPreventClick); const touchEvent = state.touchEvent; const syntheticEvent = createPressEvent( context, @@ -526,6 +529,7 @@ const pressResponderImpl = { responderRegionOnDeactivation: null, ignoreEmulatedMouseEvents: false, activePointerId: null, + shouldPreventClick: false, touchEvent: null, }; }, @@ -563,6 +567,7 @@ const pressResponderImpl = { return; } + state.shouldPreventClick = false; if (isTouchEvent) { state.ignoreEmulatedMouseEvents = true; } else if (isKeyboardEvent) { @@ -582,6 +587,7 @@ const pressResponderImpl = { !altKey ) { nativeEvent.preventDefault(); + state.shouldPreventClick = true; } } else { return; @@ -639,6 +645,9 @@ const pressResponderImpl = { } case 'click': { + if (state.shouldPreventClick) { + nativeEvent.preventDefault(); + } const onPress = props.onPress; if (isFunction(onPress) && isScreenReaderVirtualClick(nativeEvent)) { @@ -742,6 +751,7 @@ const pressResponderImpl = { case 'touchend': { if (isPressed) { const buttons = state.buttons; + let isKeyboardEvent = false; let touchEvent; if ( type === 'pointerup' && @@ -760,13 +770,79 @@ const pressResponderImpl = { 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 ( + target !== null && + 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 && + target !== 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; @@ -779,12 +855,6 @@ const pressResponderImpl = { if (previousPointerType !== 'keyboard') { removeRootEventTypes(context, state); } - - const pressTarget = state.pressTarget; - const onPress = props.onPress; - if (pressTarget !== null && isFunction(onPress)) { - dispatchEvent(event, onPress, context, state, 'press', DiscreteEvent); - } break; } diff --git a/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js index ffa33378e1..abb9518890 100644 --- a/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/PressLegacy-test.internal.js @@ -469,12 +469,15 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); // @gate experimental - it('is called after valid "click" event', () => { + it('is called after valid "keyup" event', () => { componentInit(); const target = createEventTarget(ref.current); - target.pointerdown(); - target.pointerup(); + target.keydown({key: 'Enter'}); + target.keyup({key: 'Enter'}); expect(onPress).toHaveBeenCalledTimes(1); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'keyboard', type: 'press'}), + ); }); // @gate experimental @@ -801,6 +804,40 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { }); }); + describe('beyond bounds of hit rect', () => { + /** ┌──────────────────┐ + * │ ┌────────────┐ │ + * │ │ VisualRect │ │ + * │ └────────────┘ │ + * │ HitRect │ + * └──────────────────┘ + * X <= Move to X and release + */ + // @gate experimental + it('"onPress" is not called on release', () => { + componentInit(); + 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', + ]); + }); + }); + // @gate experimental it('"onPress" is called on re-entry to hit rect', () => { componentInit(); @@ -889,8 +926,8 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { 'pointerdown', 'inner: onPressEnd', 'inner: onPressChange', - 'pointerup', 'inner: onPress', + 'pointerup', ]); }); } @@ -986,6 +1023,7 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { // @gate experimental it('prevents native behavior by default', () => { const onPress = jest.fn(); + const preventDefault = jest.fn(); const ref = React.createRef(); const Component = () => { @@ -996,8 +1034,79 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => { const target = createEventTarget(ref.current); target.pointerdown(); - target.pointerup(); - expect(onPress).toBeCalled(); + target.pointerup({preventDefault}); + expect(preventDefault).toBeCalled(); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({defaultPrevented: true}), + ); + }); + + // @gate experimental + 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', preventDefault}); + target.keyup({key: 'Enter'}); + expect(preventDefault).toBeCalled(); + expect(onPress).toHaveBeenCalledWith( + expect.objectContaining({defaultPrevented: true}), + ); + }); + + // @gate experimental + 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 ( + +