[react-ui/events] Tap responder API changes (#16827)

This patch limits the `onTap*` callbacks to the primary pointer button.
Auxiliary button and modified primary button interactions call
`onAuxiliaryTap`, cancel any active tap, and preserve the native behavior.
This commit is contained in:
Nicolas Gallagher
2019-09-19 09:36:29 -07:00
committed by GitHub
parent 4ddcb8e134
commit fd870e6b6a
7 changed files with 253 additions and 142 deletions
+16 -3
View File
@@ -34,7 +34,7 @@ type PressEventType =
type PressEvent = {|
altKey: boolean,
buttons: null | 0 | 1 | 4,
buttons: null | 1 | 4,
ctrlKey: boolean,
defaultPrevented: boolean,
key: null | string,
@@ -53,7 +53,7 @@ type PressEvent = {|
function createGestureState(e: any, type: PressEventType): PressEvent {
return {
altKey: e.altKey,
buttons: e.buttons,
buttons: e.type === 'tap:auxiliary' ? 4 : 1,
ctrlKey: e.ctrlKey,
defaultPrevented: e.defaultPrevented,
key: e.key,
@@ -103,6 +103,19 @@ export function usePress(props: PressProps) {
const tap = useTap({
disabled: disabled || active === 'keyboard',
preventDefault,
onAuxiliaryTap(e) {
if (onPressStart != null) {
onPressStart(createGestureState(e, 'pressstart'));
}
if (onPressEnd != null) {
onPressEnd(createGestureState(e, 'pressend'));
}
// Here we rely on Tap only calling 'onAuxiliaryTap' with modifiers when
// the primary button is pressed
if (onPress != null && (e.metaKey || e.shiftKey)) {
onPress(createGestureState(e, 'press'));
}
},
onTapStart(e) {
if (active == null) {
updateActive('tap');
@@ -124,7 +137,7 @@ export function usePress(props: PressProps) {
if (onPressEnd != null) {
onPressEnd(createGestureState(e, 'pressend'));
}
if (onPress != null && e.buttons !== 4) {
if (onPress != null) {
onPress(createGestureState(e, 'press'));
}
updateActive(null);
+92 -50
View File
@@ -17,18 +17,18 @@ import type {ReactEventResponderListener} from 'shared/ReactTypes';
import React from 'react';
import {
buttonsEnum,
hasPointerEvents,
isMac,
dispatchDiscreteEvent,
dispatchUserBlockingEvent,
getTouchById,
hasModifierKey,
hasPointerEvents,
} from './shared';
type TapProps = $ReadOnly<{|
disabled?: boolean,
maximumDistance?: number,
preventDefault?: boolean,
onAuxiliaryTap?: (e: TapEvent) => void,
onTapCancel?: (e: TapEvent) => void,
onTapChange?: boolean => void,
onTapEnd?: (e: TapEvent) => void,
@@ -38,7 +38,6 @@ type TapProps = $ReadOnly<{|
type TapGestureState = {|
altKey: boolean,
buttons: 0 | 1 | 4,
ctrlKey: boolean,
height: number,
metaKey: boolean,
@@ -67,13 +66,15 @@ type TapState = {|
ignoreEmulatedEvents: boolean,
initialPosition: {|x: number, y: number|},
isActive: boolean,
isAuxiliaryActive: boolean,
pointerType: PointerType,
responderTarget: null | Element,
rootEvents: null | Array<string>,
shouldPreventClick: boolean,
shouldPreventDefault: boolean,
|};
type TapEventType =
| 'tap:auxiliary'
| 'tap:cancel'
| 'tap:change'
| 'tap:end'
@@ -125,14 +126,14 @@ function createInitialState(): TapState {
buttons: 0,
ignoreEmulatedEvents: false,
isActive: false,
isAuxiliaryActive: false,
initialPosition: {x: 0, y: 0},
pointerType: '',
responderTarget: null,
rootEvents: null,
shouldPreventClick: true,
shouldPreventDefault: true,
gestureState: {
altKey: false,
buttons: 0,
ctrlKey: false,
height: 1,
metaKey: false,
@@ -187,7 +188,6 @@ function createPointerEventGestureState(
return {
altKey,
buttons: state.buttons,
ctrlKey,
height,
metaKey,
@@ -249,7 +249,6 @@ function createFallbackGestureState(
return {
altKey,
buttons: state.buttons != null ? state.buttons : 1,
ctrlKey,
height: !isCancelType && radiusY != null ? radiusY * 2 : 1,
metaKey,
@@ -351,19 +350,23 @@ function isActivePointer(
}
}
function isAuxiliary(buttons: number, nativeEvent: any): boolean {
return (
// middle-click
buttons === buttonsEnum.auxiliary ||
// open-in-new-tab
(buttons === buttonsEnum.primary && nativeEvent.metaKey) ||
// open-in-new-window
(buttons === buttonsEnum.primary && nativeEvent.shiftKey)
);
}
function shouldActivate(event: ReactDOMResponderEvent): boolean {
const nativeEvent: any = event.nativeEvent;
const pointerType = event.pointerType;
const buttons = nativeEvent.buttons;
const isContextMenu = pointerType === 'mouse' && nativeEvent.ctrlKey && isMac;
const isValidButton =
buttons === buttonsEnum.primary || buttons === buttonsEnum.middle;
if (pointerType === 'touch' || (isValidButton && !isContextMenu)) {
return true;
} else {
return false;
}
const isValidButton = buttons === buttonsEnum.primary;
return pointerType === 'touch' || (isValidButton && !hasModifierKey(event));
}
/**
@@ -418,7 +421,7 @@ function dispatchEnd(
const onTapEnd = props.onTapEnd;
dispatchChange(context, props, state);
if (onTapEnd != null) {
const defaultPrevented = state.shouldPreventClick === true;
const defaultPrevented = state.shouldPreventDefault === true;
const payload = context.objectAssign({}, state.gestureState, {
defaultPrevented,
type,
@@ -441,6 +444,22 @@ function dispatchCancel(
}
}
function dispatchAuxiliaryTap(
context: ReactDOMResponderContext,
props: TapProps,
state: TapState,
): void {
const type = 'tap:auxiliary';
const onAuxiliaryTap = props.onAuxiliaryTap;
if (onAuxiliaryTap != null) {
const payload = context.objectAssign({}, state.gestureState, {
defaultPrevented: false,
type,
});
dispatchDiscreteEvent(context, payload, onAuxiliaryTap);
}
}
/**
* Responder implementation
*/
@@ -493,26 +512,41 @@ const responderImpl = {
}
}
if (!state.isActive && shouldActivate(event)) {
state.isActive = true;
state.buttons = nativeEvent.buttons;
state.pointerType = event.pointerType;
state.responderTarget = context.getResponderNode();
state.shouldPreventClick = props.preventDefault !== false;
if (!state.isActive) {
const activate = shouldActivate(event);
const activateAuxiliary = isAuxiliary(
nativeEvent.buttons,
nativeEvent,
);
const gestureState = createGestureState(context, props, state, event);
state.gestureState = gestureState;
state.initialPosition.x = gestureState.x;
state.initialPosition.y = gestureState.y;
dispatchStart(context, props, state);
addRootEventTypes(rootEventTypes, context, state);
if (!hasPointerEvents) {
if (eventType === 'touchstart') {
state.ignoreEmulatedEvents = true;
if (activate || activateAuxiliary) {
state.buttons = nativeEvent.buttons;
state.pointerType = event.pointerType;
state.responderTarget = context.getResponderNode();
addRootEventTypes(rootEventTypes, context, state);
if (!hasPointerEvents) {
if (eventType === 'touchstart') {
state.ignoreEmulatedEvents = true;
}
}
}
if (activate) {
const gestureState = createGestureState(
context,
props,
state,
event,
);
state.isActive = true;
state.shouldPreventDefault = props.preventDefault !== false;
state.gestureState = gestureState;
state.initialPosition.x = gestureState.x;
state.initialPosition.y = gestureState.y;
dispatchStart(context, props, state);
} else if (activateAuxiliary) {
state.isAuxiliaryActive = true;
}
}
break;
}
@@ -575,24 +609,30 @@ const responderImpl = {
case 'mouseup':
case 'touchend': {
if (state.isActive && isActivePointer(event, state)) {
if (state.buttons === buttonsEnum.middle) {
// Remove the root events here as no 'click' event is dispatched
// when this 'button' is pressed.
removeRootEventTypes(context, state);
}
state.gestureState = createGestureState(context, props, state, event);
state.isActive = false;
if (context.isTargetWithinResponder(hitTarget)) {
// Determine whether to call preventDefault on subsequent native events.
if (hasModifierKey(event)) {
state.shouldPreventClick = false;
}
dispatchEnd(context, props, state);
} else {
if (isAuxiliary(state.buttons, nativeEvent)) {
dispatchCancel(context, props, state);
dispatchAuxiliaryTap(context, props, state);
// Remove the root events here as no 'click' event is dispatched
removeRootEventTypes(context, state);
} else if (
!context.isTargetWithinResponder(hitTarget) ||
hasModifierKey(event)
) {
dispatchCancel(context, props, state);
} else {
dispatchEnd(context, props, state);
}
} else if (
state.isAuxiliaryActive &&
isAuxiliary(state.buttons, nativeEvent)
) {
state.isAuxiliaryActive = false;
state.gestureState = createGestureState(context, props, state, event);
dispatchAuxiliaryTap(context, props, state);
// Remove the root events here as no 'click' event is dispatched
removeRootEventTypes(context, state);
}
if (!hasPointerEvents) {
@@ -612,6 +652,7 @@ const responderImpl = {
state.gestureState = createGestureState(context, props, state, event);
state.isActive = false;
dispatchCancel(context, props, state);
removeRootEventTypes(context, state);
}
break;
}
@@ -630,12 +671,13 @@ const responderImpl = {
state.gestureState = createGestureState(context, props, state, event);
state.isActive = false;
dispatchCancel(context, props, state);
removeRootEventTypes(context, state);
}
break;
}
case 'click': {
if (state.shouldPreventClick) {
if (state.shouldPreventDefault) {
nativeEvent.preventDefault();
}
removeRootEventTypes(context, state);
@@ -125,11 +125,13 @@ describeWithPointerEvent('Press responder', hasPointerEvents => {
it('is called after middle-button pointer down', () => {
const target = createEventTarget(ref.current);
target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'});
const pointerType = 'mouse';
target.pointerdown({buttons: buttonsType.auxiliary, pointerType});
target.pointerup({pointerType});
expect(onPressStart).toHaveBeenCalledTimes(1);
expect(onPressStart).toHaveBeenCalledWith(
expect.objectContaining({
buttons: buttonsType.middle,
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
type: 'pressstart',
}),
@@ -209,12 +211,15 @@ describeWithPointerEvent('Press responder', hasPointerEvents => {
it('is called after middle-button pointer up', () => {
const target = createEventTarget(ref.current);
target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'});
target.pointerdown({
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
});
target.pointerup({pointerType: 'mouse'});
expect(onPressEnd).toHaveBeenCalledTimes(1);
expect(onPressEnd).toHaveBeenCalledWith(
expect.objectContaining({
buttons: buttonsType.middle,
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
type: 'pressend',
}),
@@ -350,7 +355,10 @@ describeWithPointerEvent('Press responder', hasPointerEvents => {
it('is not called after middle-button press', () => {
const target = createEventTarget(ref.current);
target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'});
target.pointerdown({
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
});
target.pointerup({pointerType: 'mouse'});
expect(onPress).not.toHaveBeenCalled();
});
@@ -120,11 +120,14 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => {
it('is called after middle-button pointer down', () => {
const target = createEventTarget(ref.current);
target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'});
target.pointerdown({
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
});
expect(onPressStart).toHaveBeenCalledTimes(1);
expect(onPressStart).toHaveBeenCalledWith(
expect.objectContaining({
buttons: buttonsType.middle,
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
type: 'pressstart',
}),
@@ -135,7 +138,10 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => {
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.pointerdown({
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
});
target.pointerup({pointerType: 'mouse'});
target.pointerhover({x: 110, y: 110});
target.pointerhover({x: 50, y: 50});
@@ -215,12 +221,15 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => {
it('is called after middle-button pointer up', () => {
const target = createEventTarget(ref.current);
target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'});
target.pointerdown({
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
});
target.pointerup({pointerType: 'mouse'});
expect(onPressEnd).toHaveBeenCalledTimes(1);
expect(onPressEnd).toHaveBeenCalledWith(
expect.objectContaining({
buttons: buttonsType.middle,
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
type: 'pressend',
}),
@@ -356,7 +365,10 @@ describe.each(environmentTable)('Press responder', hasPointerEvents => {
it('is not called after middle-button press', () => {
const target = createEventTarget(ref.current);
target.pointerdown({buttons: buttonsType.middle, pointerType: 'mouse'});
target.pointerdown({
buttons: buttonsType.auxiliary,
pointerType: 'mouse',
});
target.pointerup({pointerType: 'mouse'});
expect(onPress).not.toHaveBeenCalled();
});
@@ -193,6 +193,39 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
});
});
describe('onAuxiliaryTap', () => {
let onAuxiliaryTap, ref;
beforeEach(() => {
onAuxiliaryTap = jest.fn();
ref = React.createRef();
const Component = () => {
const listener = useTap({onAuxiliaryTap});
return <div ref={ref} listeners={listener} />;
};
ReactDOM.render(<Component />, container);
document.elementFromPoint = () => ref.current;
});
test('auxiliary-button pointer up', () => {
const pointerType = 'mouse';
const buttons = buttonsType.auxiliary;
const target = createEventTarget(ref.current);
target.pointerdown({buttons, pointerType});
target.pointerup({pointerType});
expect(onAuxiliaryTap).toHaveBeenCalledTimes(1);
});
test('modifier-button pointer up', () => {
const pointerType = 'mouse';
const buttons = buttonsType.primary;
const target = createEventTarget(ref.current);
target.pointerdown({buttons, pointerType});
target.pointerup({metaKey: true, pointerType});
expect(onAuxiliaryTap).toHaveBeenCalledTimes(1);
});
});
describe('onTapStart', () => {
let onTapStart, ref;
@@ -210,6 +243,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
testWithPointerType('pointer down', pointerType => {
const target = createEventTarget(ref.current);
const nativeEvent = {
buttons: buttonsType.primary,
pageX: 10,
pageY: 10,
pointerType,
@@ -226,7 +260,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
expect(onTapStart).toHaveBeenCalledWith(
expect.objectContaining({
altKey: false,
buttons: 1,
ctrlKey: false,
height: pointerType === 'mouse' ? 1 : 23,
metaKey: false,
@@ -269,36 +302,31 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
expect(onTapStart).toHaveBeenCalledTimes(1);
});
test('primary-button pointer down', () => {
const pointerType = 'mouse';
const buttons = buttonsType.primary;
const target = createEventTarget(ref.current);
target.pointerdown({buttons, pointerType});
expect(onTapStart).toHaveBeenCalledTimes(1);
expect(onTapStart).toHaveBeenCalledWith(
expect.objectContaining({buttons, pointerType}),
);
});
test('middle-button pointer down', () => {
const pointerType = 'mouse';
const buttons = buttonsType.middle;
const target = createEventTarget(ref.current);
target.pointerdown({buttons, pointerType});
expect(onTapStart).toHaveBeenCalledTimes(1);
expect(onTapStart).toHaveBeenCalledWith(
expect.objectContaining({buttons, pointerType}),
);
});
test('unsupported buttons', () => {
test('ignored buttons and modifiers', () => {
const target = createEventTarget(ref.current);
const primary = buttonsType.primary;
// right-click
target.pointerdown({buttons: buttonsType.secondary});
target.pointerup();
// middle-click
target.pointerdown({buttons: buttonsType.auxiliary});
target.pointerup();
// pen eraser
target.pointerdown({buttons: buttonsType.eraser});
target.pointerup();
// alt-click
target.pointerdown({buttons: primary, altKey: true});
target.pointerup();
// ctrl-click
target.pointerdown({buttons: primary, ctrlKey: true});
target.pointerup();
// meta-click
target.pointerdown({buttons: primary, metaKey: true});
target.pointerup();
// shift-click
target.pointerdown({buttons: primary, shiftKey: true});
target.pointerup();
expect(onTapStart).toHaveBeenCalledTimes(0);
});
});
@@ -319,7 +347,7 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
testWithPointerType('pointer up', pointerType => {
const target = createEventTarget(ref.current);
target.pointerdown({pointerType});
target.pointerdown({buttons: buttonsType.primary, pointerType});
target.pointerup({
pageX: 10,
pageY: 10,
@@ -331,7 +359,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
expect(onTapEnd).toHaveBeenCalledWith(
expect.objectContaining({
altKey: false,
buttons: 1,
ctrlKey: false,
height: pointerType === 'mouse' ? 1 : 23,
metaKey: false,
@@ -356,30 +383,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
);
});
test('primary-button pointer up', () => {
const pointerType = 'mouse';
const buttons = buttonsType.primary;
const target = createEventTarget(ref.current);
target.pointerdown({buttons, pointerType});
target.pointerup({pointerType});
expect(onTapEnd).toHaveBeenCalledTimes(1);
expect(onTapEnd).toHaveBeenCalledWith(
expect.objectContaining({buttons, pointerType}),
);
});
test('middle-button pointer up', () => {
const pointerType = 'mouse';
const buttons = buttonsType.middle;
const target = createEventTarget(ref.current);
target.pointerdown({buttons, pointerType});
target.pointerup({pointerType});
expect(onTapEnd).toHaveBeenCalledTimes(1);
expect(onTapEnd).toHaveBeenCalledWith(
expect.objectContaining({buttons, pointerType}),
);
});
testWithPointerType('zero-dimension hit rect', pointerType => {
const targetRef = React.createRef();
const innerRef = React.createRef();
@@ -414,6 +417,34 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
});
expect(onTapEnd).not.toBeCalled();
});
test('ignored buttons and modifiers', () => {
const target = createEventTarget(ref.current);
const primary = buttonsType.primary;
// right-click
target.pointerdown({buttons: buttonsType.secondary});
target.pointerup();
// middle-click
target.pointerdown({buttons: buttonsType.auxiliary});
target.pointerup();
// pen eraser
target.pointerdown({buttons: buttonsType.eraser});
target.pointerup();
// alt-click
target.pointerdown({buttons: primary});
target.pointerup({altKey: true});
// ctrl-click
target.pointerdown({buttons: primary});
target.pointerup({ctrlKey: true});
// meta-click
target.pointerdown({buttons: primary});
target.pointerup({metaKey: true});
// shift-click
target.pointerdown({buttons: primary});
target.pointerup({shiftKey: true});
expect(onTapEnd).toHaveBeenCalledTimes(0);
});
});
describe('onTapUpdate', () => {
@@ -449,7 +480,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
expect(onTapUpdate).toHaveBeenCalledWith(
expect.objectContaining({
altKey: false,
buttons: 1,
ctrlKey: false,
height: pointerType === 'mouse' ? 1 : 23,
metaKey: false,
@@ -594,7 +624,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
expect(onTapCancel).toHaveBeenCalledWith(
expect.objectContaining({
altKey: false,
buttons: buttonsType.primary,
ctrlKey: false,
height: 1,
metaKey: false,
@@ -621,6 +650,37 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
expect(onTapUpdate).not.toBeCalled();
});
testWithPointerType('pointer move outside target', pointerType => {
const downTarget = createEventTarget(ref.current);
const upTarget = createEventTarget(container);
tapAndMoveOutside({
hasPointerEvents,
downTarget,
upTarget,
pointerType,
});
expect(onTapCancel).toBeCalled();
});
test('ignored modifiers', () => {
const target = createEventTarget(ref.current);
const primary = buttonsType.primary;
// alt-click
target.pointerdown({buttons: primary});
target.pointerup({altKey: true});
// ctrl-click
target.pointerdown({buttons: primary});
target.pointerup({ctrlKey: true});
// meta-click
target.pointerdown({buttons: primary});
target.pointerup({metaKey: true});
// shift-click
target.pointerdown({buttons: primary});
target.pointerup({shiftKey: true});
expect(onTapCancel).toHaveBeenCalledTimes(4);
});
test('long press context menu', () => {
const target = createEventTarget(ref.current);
target.contextmenu({}, {pointerType: 'touch'});
@@ -659,18 +719,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
containerTarget.scroll();
expect(onTapCancel).toHaveBeenCalledTimes(1);
});
testWithPointerType('pointer move outside target', pointerType => {
const downTarget = createEventTarget(ref.current);
const upTarget = createEventTarget(container);
tapAndMoveOutside({
hasPointerEvents,
downTarget,
upTarget,
pointerType,
});
expect(onTapCancel).toBeCalled();
});
});
describe('preventDefault', () => {
@@ -718,18 +766,6 @@ describeWithPointerEvent('Tap responder', hasPointerEvents => {
);
});
test('allows native behaviour by default (modifier keys)', () => {
['metaKey', 'ctrlKey', 'shiftKey'].forEach(modifierKey => {
const target = createEventTarget(ref.current);
target.pointerdown({[modifierKey]: true});
target.pointerup({[modifierKey]: true, preventDefault});
expect(preventDefault).not.toBeCalled();
expect(onTapEnd).toHaveBeenCalledWith(
expect.objectContaining({defaultPrevented: false}),
);
});
});
test('allows native behaviour if false', () => {
remount(false);
+1 -1
View File
@@ -27,7 +27,7 @@ export const buttonsEnum = {
none: 0,
primary: 1,
secondary: 2,
middle: 4,
auxiliary: 4,
};
export function dispatchDiscreteEvent(
@@ -78,7 +78,7 @@ export const buttonsType = {
// pen barrel button
secondary: 2,
// middle mouse
middle: 4,
auxiliary: 4,
// back mouse
back: 8,
// forward mouse