@samitouri / QOS-React-2 / commits / 9ce8711d5a

[react-events] Tap responder (#16628)

This is a partial replacement for the 'Press' responder: 1. `useTap` is scoped to pointers (no keyboard support). Our current thinking is that "responders" should be limited to working with pointers, and that they can be combined with 'useKeyboard' in user-space. For example, we might create a 'usePress' hook in user-space that combines 'useTap' with 'useKeyboard' to react to both pointers and keyboard interactions. 2. `useTap` cancels the gesture once the pointer moves over an element that is not within the responder target's subtree. This differs from `usePress` (and React Native), where the gesture remains active after the pointer exits the target's subtree and is restarted once the pointer reenters. One of the drawbacks with the `usePress` behavior is that it requires repeatedly measuring DOM elements (which can cause jank) to perform hit region tests. `useTap` avoids doing this and relies on `document.elementFromPoint` only to support the TouchEvent fallbacks. 3. `useTap` calls `onTapUpdate` when the active gesture's state changes, `onTapEnd` when the gesture successfully completes. and `onTapCancel` when it fails. There is no `onTap` callback. `usePress` did not explicitly report back when the gesture failed, and product developers were confused about the difference between `onPress` and `onPressEnd`. 4. `useTap` explicitly separates the PointerEvent implementation from the MouseEvent/TouchEvent fallback. 5. `useTap` has better unit test coverage . All pointer types and the fallback environment are tested. The shape of the gesture state object is also defined and tested.

Nicolas Gallagher committed Sep 4, 2019 at 17:09 UTC 9ce8711d5a7c84e71587c75d2935966023ee6dbb
14 files changed +1470 -42
packages/react-dom/src/events/DOMEventResponderSystem.js
+3 -6
@@ -119,7 +119,7 @@ const eventResponderContext: ReactDOMResponderContext = {
119 }
120 }
121 },
122 - isTargetWithinResponder(target: Element | Document): boolean {
122 + isTargetWithinResponder(target: null | Element | Document): boolean {
123 validateResponderContext();
124 if (target != null) {
125 let fiber = getClosestInstanceFromNode(target);
@@ -135,7 +135,7 @@ const eventResponderContext: ReactDOMResponderContext = {
135 }
136 return false;
137 },
138 - isTargetWithinResponderScope(target: Element | Document): boolean {
138 + isTargetWithinResponderScope(target: null | Element | Document): boolean {
139 validateResponderContext();
140 const componentInstance = ((currentInstance: any): ReactDOMEventResponderInstance);
141 const responder = componentInstance.responder;
@@ -158,7 +158,7 @@ const eventResponderContext: ReactDOMResponderContext = {
158 return false;
159 },
160 isTargetWithinNode(
161 - childTarget: Element | Document,
161 + childTarget: null | Element | Document,
162 parentTarget: Element | Document,
163 ): boolean {
164 validateResponderContext();
@@ -390,11 +390,9 @@ function createDOMResponderEvent(
390 ): ReactDOMResponderEvent {
391 const {buttons, pointerType} = (nativeEvent: any);
392 let eventPointerType = '';
393 - let pointerId = null;
393
394 if (pointerType !== undefined) {
395 eventPointerType = pointerType;
397 - pointerId = (nativeEvent: any).pointerId;
396 } else if (nativeEvent.key !== undefined) {
397 eventPointerType = 'keyboard';
398 } else if (buttons !== undefined) {
@@ -407,7 +405,6 @@ function createDOMResponderEvent(
405 nativeEvent: nativeEvent,
406 passive,
407 passiveSupported,
410 - pointerId,
408 pointerType: eventPointerType,
409 target: nativeEventTarget,
410 type: topLevelType,
packages/react-events/npm/tap.js new
+7
@@ -0,0 +1,7 @@
1 +'use strict';
2 +
3 +if (process.env.NODE_ENV === 'production') {
4 + module.exports = require('./cjs/react-events-tap.production.min.js');
5 +} else {
6 + module.exports = require('./cjs/react-events-tap.development.js');
7 +}
packages/react-events/package.json
+1
@@ -21,6 +21,7 @@
21 "press.js",
22 "scroll.js",
23 "swipe.js",
24 + "tap.js",
25 "build-info.json",
26 "cjs/",
27 "umd/"
packages/react-events/src/dom/Press.js
+11 -5
@@ -521,7 +521,7 @@ const pressResponderImpl = {
521 props: PressProps,
522 state: PressState,
523 ): void {
524 - const {pointerId, pointerType, type} = event;
524 + const {pointerType, type} = event;
525
526 if (props.disabled) {
527 removeRootEventTypes(context, state);
@@ -584,7 +584,7 @@ const pressResponderImpl = {
584 state.pointerType = pointerType;
585 const pressTarget = (state.pressTarget = context.getResponderNode());
586 if (isPointerEvent) {
587 - state.activePointerId = pointerId;
587 + state.activePointerId = nativeEvent.pointerId;
588 } else if (isTouchEvent) {
589 const touchEvent = getTouchFromPressEvent(nativeEvent);
590 if (touchEvent === null) {
@@ -652,7 +652,7 @@ const pressResponderImpl = {
652 props: PressProps,
653 state: PressState,
654 ): void {
655 - let {pointerId, pointerType, target, type} = event;
655 + let {pointerType, target, type} = event;
656
657 const nativeEvent: any = event.nativeEvent;
658 const isPressed = state.isPressed;
@@ -672,7 +672,10 @@ const pressResponderImpl = {
672 if (previousPointerType !== pointerType) {
673 return;
674 }
675 - if (type === 'pointermove' && activePointerId !== pointerId) {
675 + if (
676 + type === 'pointermove' &&
677 + activePointerId !== nativeEvent.pointerId
678 + ) {
679 return;
680 } else if (type === 'touchmove') {
681 touchEvent = getTouchById(nativeEvent, activePointerId);
@@ -733,7 +736,10 @@ const pressResponderImpl = {
736 const buttons = state.buttons;
737 let isKeyboardEvent = false;
738 let touchEvent;
736 - if (type === 'pointerup' && activePointerId !== pointerId) {
739 + if (
740 + type === 'pointerup' &&
741 + activePointerId !== nativeEvent.pointerId
742 + ) {
743 return;
744 } else if (type === 'touchend') {
745 touchEvent = getTouchById(nativeEvent, activePointerId);
packages/react-events/src/dom/Tap.js new
+663
@@ -0,0 +1,663 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {
11 + ReactDOMResponderContext,
12 + ReactDOMResponderEvent,
13 + PointerType,
14 +} from 'shared/ReactDOMTypes';
15 +import type {ReactEventResponderListener} from 'shared/ReactTypes';
16 +
17 +import React from 'react';
18 +import {
19 + buttonsEnum,
20 + hasPointerEvents,
21 + isMac,
22 + dispatchDiscreteEvent,
23 + dispatchUserBlockingEvent,
24 +} from './shared';
25 +
26 +type TapProps = {|
27 + disabled: boolean,
28 + preventDefault: boolean,
29 + onTapCancel: (e: TapEvent) => void,
30 + onTapChange: boolean => void,
31 + onTapEnd: (e: TapEvent) => void,
32 + onTapStart: (e: TapEvent) => void,
33 + onTapUpdate: (e: TapEvent) => void,
34 +|};
35 +
36 +type TapState = {
37 + activePointerId: null | number,
38 + buttons: 0 | 1 | 4,
39 + gestureState: TapGestureState,
40 + ignoreEmulatedEvents: boolean,
41 + isActive: boolean,
42 + pointerType: PointerType,
43 + responderTarget: null | Element,
44 + rootEvents: null | Array<string>,
45 + shouldPreventClick: boolean,
46 +};
47 +
48 +type TapEventType =
49 + | 'tap-cancel'
50 + | 'tap-change'
51 + | 'tap-end'
52 + | 'tap-start'
53 + | 'tap-update';
54 +
55 +type TapGestureState = {|
56 + altKey: boolean,
57 + buttons: 0 | 1 | 4,
58 + ctrlKey: boolean,
59 + height: number,
60 + metaKey: boolean,
61 + pageX: number,
62 + pageY: number,
63 + pointerType: PointerType,
64 + pressure: number,
65 + screenX: number,
66 + screenY: number,
67 + shiftKey: boolean,
68 + tangentialPressure: number,
69 + target: null | Element,
70 + tiltX: number,
71 + tiltY: number,
72 + timeStamp: number,
73 + twist: number,
74 + width: number,
75 + x: number,
76 + y: number,
77 +|};
78 +
79 +type TapEvent = {|
80 + ...TapGestureState,
81 + type: TapEventType,
82 +|};
83 +
84 +/**
85 + * Native event dependencies
86 + */
87 +
88 +const targetEventTypes = hasPointerEvents
89 + ? ['pointerdown']
90 + : ['mousedown', 'touchstart'];
91 +
92 +const rootEventTypes = hasPointerEvents
93 + ? [
94 + 'click_active',
95 + 'contextmenu',
96 + 'pointerup',
97 + 'pointermove',
98 + 'pointercancel',
99 + 'scroll',
100 + ]
101 + : [
102 + 'click_active',
103 + 'contextmenu',
104 + 'mouseup',
105 + 'mousemove',
106 + 'dragstart',
107 + 'touchend',
108 + 'touchmove',
109 + 'touchcancel',
110 + 'scroll',
111 + ];
112 +
113 +/**
114 + * Responder and gesture state
115 + */
116 +
117 +function createInitialState(): TapState {
118 + return {
119 + activePointerId: null,
120 + buttons: 0,
121 + ignoreEmulatedEvents: false,
122 + isActive: false,
123 + pointerType: '',
124 + responderTarget: null,
125 + rootEvents: null,
126 + shouldPreventClick: true,
127 + gestureState: {
128 + altKey: false,
129 + buttons: 0,
130 + ctrlKey: false,
131 + height: 1,
132 + metaKey: false,
133 + pageX: 0,
134 + pageY: 0,
135 + pointerType: '',
136 + pressure: 0,
137 + screenX: 0,
138 + screenY: 0,
139 + shiftKey: false,
140 + tangentialPressure: 0,
141 + target: null,
142 + tiltX: 0,
143 + tiltY: 0,
144 + timeStamp: 0,
145 + twist: 0,
146 + width: 1,
147 + x: 0,
148 + y: 0,
149 + },
150 + };
151 +}
152 +
153 +function createPointerEventGestureState(
154 + context: ReactDOMResponderContext,
155 + props: TapProps,
156 + state: TapState,
157 + event: ReactDOMResponderEvent,
158 +): TapGestureState {
159 + const timeStamp = context.getTimeStamp();
160 + const nativeEvent = (event.nativeEvent: any);
161 + const {
162 + altKey,
163 + ctrlKey,
164 + height,
165 + metaKey,
166 + pageX,
167 + pageY,
168 + pointerType,
169 + pressure,
170 + screenX,
171 + screenY,
172 + shiftKey,
173 + tangentialPressure,
174 + tiltX,
175 + tiltY,
176 + twist,
177 + width,
178 + clientX,
179 + clientY,
180 + } = nativeEvent;
181 +
182 + return {
183 + altKey,
184 + buttons: state.buttons,
185 + ctrlKey,
186 + height,
187 + metaKey,
188 + pageX,
189 + pageY,
190 + pointerType,
191 + pressure,
192 + screenX,
193 + screenY,
194 + shiftKey,
195 + tangentialPressure,
196 + target: state.responderTarget,
197 + tiltX,
198 + tiltY,
199 + timeStamp,
200 + twist,
201 + width,
202 + x: clientX,
203 + y: clientY,
204 + };
205 +}
206 +
207 +function createFallbackGestureState(
208 + context: ReactDOMResponderContext,
209 + props: TapProps,
210 + state: TapState,
211 + event: ReactDOMResponderEvent,
212 +): TapGestureState {
213 + const timeStamp = context.getTimeStamp();
214 + const nativeEvent = (event.nativeEvent: any);
215 + const eType = event.type;
216 + const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
217 + const isCancelType = eType === 'dragstart' || eType === 'touchcancel';
218 + const isEndType = eType === 'mouseup' || eType === 'touchend';
219 + const isTouchEvent = event.pointerType === 'touch';
220 +
221 + let pointerEvent = nativeEvent;
222 + if (!hasPointerEvents && isTouchEvent) {
223 + const touch = getTouchById(nativeEvent, state.activePointerId);
224 + if (touch != null) {
225 + pointerEvent = touch;
226 + }
227 + }
228 +
229 + const {
230 + pageX,
231 + pageY,
232 + // $FlowExpectedError: missing from 'Touch' typedef
233 + radiusX,
234 + // $FlowExpectedError: missing from 'Touch' typedef
235 + radiusY,
236 + // $FlowExpectedError: missing from 'Touch' typedef
237 + rotationAngle,
238 + screenX,
239 + screenY,
240 + clientX,
241 + clientY,
242 + } = pointerEvent;
243 +
244 + return {
245 + altKey,
246 + buttons: state.buttons != null ? state.buttons : 1,
247 + ctrlKey,
248 + height: !isCancelType && radiusY != null ? radiusY * 2 : 1,
249 + metaKey,
250 + pageX: isCancelType ? 0 : pageX,
251 + pageY: isCancelType ? 0 : pageY,
252 + pointerType: event.pointerType,
253 + pressure: isEndType || isCancelType ? 0 : isTouchEvent ? 1 : 0.5,
254 + screenX: isCancelType ? 0 : screenX,
255 + screenY: isCancelType ? 0 : screenY,
256 + shiftKey,
257 + tangentialPressure: 0,
258 + target: state.responderTarget,
259 + tiltX: 0,
260 + tiltY: 0,
261 + timeStamp,
262 + twist: rotationAngle != null ? rotationAngle : 0,
263 + width: !isCancelType && radiusX != null ? radiusX * 2 : 1,
264 + x: isCancelType ? 0 : clientX,
265 + y: isCancelType ? 0 : clientY,
266 + };
267 +}
268 +
269 +const createGestureState = hasPointerEvents
270 + ? createPointerEventGestureState
271 + : createFallbackGestureState;
272 +
273 +/**
274 + * Managing root events
275 + */
276 +
277 +function addRootEventTypes(
278 + rootEvents: Array<string>,
279 + context: ReactDOMResponderContext,
280 + state: TapState,
281 +): void {
282 + if (!state.rootEvents) {
283 + state.rootEvents = rootEvents;
284 + context.addRootEventTypes(state.rootEvents);
285 + }
286 +}
287 +
288 +function removeRootEventTypes(
289 + context: ReactDOMResponderContext,
290 + state: TapState,
291 +): void {
292 + if (state.rootEvents != null) {
293 + context.removeRootEventTypes(state.rootEvents);
294 + state.rootEvents = null;
295 + }
296 +}
297 +
298 +/**
299 + * Managing pointers
300 + */
301 +
302 +function getTouchById(
303 + nativeEvent: TouchEvent,
304 + pointerId: null | number,
305 +): null | Touch {
306 + if (pointerId != null) {
307 + const changedTouches = nativeEvent.changedTouches;
308 + for (let i = 0; i < changedTouches.length; i++) {
309 + const touch = changedTouches[i];
310 + if (touch.identifier === pointerId) {
311 + return touch;
312 + }
313 + }
314 + return null;
315 + }
316 + return null;
317 +}
318 +
319 +function getHitTarget(
320 + event: ReactDOMResponderEvent,
321 + context: ReactDOMResponderContext,
322 + state: TapState,
323 +): null | Element | Document {
324 + if (hasPointerEvents) {
325 + return event.target;
326 + } else {
327 + if (event.pointerType === 'touch') {
328 + const doc = context.getActiveDocument();
329 + const nativeEvent: any = event.nativeEvent;
330 + const touch = getTouchById(nativeEvent, state.activePointerId);
331 + if (touch != null) {
332 + return doc.elementFromPoint(touch.clientX, touch.clientY);
333 + } else {
334 + return null;
335 + }
336 + } else {
337 + return event.target;
338 + }
339 + }
340 +}
341 +
342 +function isActivePointer(
343 + event: ReactDOMResponderEvent,
344 + state: TapState,
345 +): boolean {
346 + const nativeEvent: any = event.nativeEvent;
347 + const activePointerId = state.activePointerId;
348 +
349 + if (hasPointerEvents) {
350 + const eventPointerId = nativeEvent.pointerId;
351 + if (activePointerId != null && eventPointerId != null) {
352 + return (
353 + state.pointerType === event.pointerType &&
354 + activePointerId === eventPointerId
355 + );
356 + } else {
357 + return true;
358 + }
359 + } else {
360 + if (event.pointerType === 'touch') {
361 + const touch = getTouchById(nativeEvent, activePointerId);
362 + return touch != null;
363 + } else {
364 + // accept all events that don't have ids
365 + return true;
366 + }
367 + }
368 +}
369 +
370 +function isModifiedTap(event: ReactDOMResponderEvent): boolean {
371 + const nativeEvent: any = event.nativeEvent;
372 + const {altKey, ctrlKey, metaKey, shiftKey} = nativeEvent;
373 + return (
374 + altKey === true || ctrlKey === true || metaKey === true || shiftKey === true
375 + );
376 +}
377 +
378 +function shouldActivate(event: ReactDOMResponderEvent): boolean {
379 + const nativeEvent: any = event.nativeEvent;
380 + const pointerType = event.pointerType;
381 + const buttons = nativeEvent.buttons;
382 + const isContextMenu = pointerType === 'mouse' && nativeEvent.ctrlKey && isMac;
383 + const isValidButton =
384 + buttons === buttonsEnum.primary || buttons === buttonsEnum.middle;
385 +
386 + if (pointerType === 'touch' || (isValidButton && !isContextMenu)) {
387 + return true;
388 + } else {
389 + return false;
390 + }
391 +}
392 +
393 +/**
394 + * Communicating gesture state back to components
395 + */
396 +
397 +function dispatchStart(
398 + context: ReactDOMResponderContext,
399 + props: TapProps,
400 + state: TapState,
401 +): void {
402 + const type = 'tap:start';
403 + const onTapStart = props.onTapStart;
404 + if (onTapStart != null) {
405 + const payload = {...state.gestureState, type};
406 + dispatchDiscreteEvent(context, payload, onTapStart);
407 + }
408 +}
409 +
410 +function dispatchChange(
411 + context: ReactDOMResponderContext,
412 + props: TapProps,
413 + state: TapState,
414 +): void {
415 + const onTapChange = props.onTapChange;
416 + if (onTapChange != null) {
417 + const payload = state.isActive;
418 + dispatchDiscreteEvent(context, payload, onTapChange);
419 + }
420 +}
421 +
422 +function dispatchUpdate(
423 + context: ReactDOMResponderContext,
424 + props: TapProps,
425 + state: TapState,
426 +) {
427 + const type = 'tap:update';
428 + const onTapUpdate = props.onTapUpdate;
429 + if (onTapUpdate != null) {
430 + const payload = {...state.gestureState, type};
431 + dispatchUserBlockingEvent(context, payload, onTapUpdate);
432 + }
433 +}
434 +
435 +function dispatchEnd(
436 + context: ReactDOMResponderContext,
437 + props: TapProps,
438 + state: TapState,
439 +): void {
440 + const type = 'tap:end';
441 + const onTapEnd = props.onTapEnd;
442 + if (onTapEnd != null) {
443 + const payload = {...state.gestureState, type};
444 + dispatchDiscreteEvent(context, payload, onTapEnd);
445 + }
446 +}
447 +
448 +function dispatchCancel(
449 + context: ReactDOMResponderContext,
450 + props: TapProps,
451 + state: TapState,
452 +): void {
453 + const type = 'tap:cancel';
454 + const onTapCancel = props.onTapCancel;
455 + if (onTapCancel != null) {
456 + const payload = {...state.gestureState, type};
457 + dispatchDiscreteEvent(context, payload, onTapCancel);
458 + }
459 +}
460 +
461 +/**
462 + * Responder implementation
463 + */
464 +
465 +const responderImpl = {
466 + targetEventTypes,
467 + getInitialState(): TapState {
468 + return createInitialState();
469 + },
470 + onEvent(
471 + event: ReactDOMResponderEvent,
472 + context: ReactDOMResponderContext,
473 + props: TapProps,
474 + state: TapState,
475 + ): void {
476 + if (props.disabled) {
477 + removeRootEventTypes(context, state);
478 + if (state.isActive) {
479 + dispatchCancel(context, props, state);
480 + state.isActive = false;
481 + }
482 + return;
483 + }
484 +
485 + const nativeEvent: any = event.nativeEvent;
486 + const eventTarget: Element = nativeEvent.target;
487 + const eventType = event.type;
488 +
489 + switch (eventType) {
490 + // START
491 + case 'pointerdown':
492 + case 'mousedown':
493 + case 'touchstart': {
494 + if (hasPointerEvents) {
495 + const pointerId = nativeEvent.pointerId;
496 + state.activePointerId = pointerId;
497 + // Make mouse and touch pointers consistent.
498 + // Flow bug: https://github.com/facebook/flow/issues/8055
499 + // $FlowExpectedError
500 + eventTarget.releasePointerCapture(pointerId);
501 + } else {
502 + if (eventType === 'touchstart') {
503 + const targetTouches = nativeEvent.targetTouches;
504 + if (targetTouches.length > 0) {
505 + state.activePointerId = targetTouches[0].identifier;
506 + }
507 + }
508 + if (eventType === 'mousedown' && state.ignoreEmulatedEvents) {
509 + return;
510 + }
511 + }
512 +
513 + if (!state.isActive && shouldActivate(event)) {
514 + state.isActive = true;
515 + state.buttons = nativeEvent.buttons;
516 + state.pointerType = event.pointerType;
517 + state.responderTarget = context.getResponderNode();
518 + state.shouldPreventClick = props.preventDefault !== false;
519 + state.gestureState = createGestureState(context, props, state, event);
520 + dispatchStart(context, props, state);
521 + dispatchChange(context, props, state);
522 + addRootEventTypes(rootEventTypes, context, state);
523 +
524 + if (!hasPointerEvents) {
525 + if (eventType === 'touchstart') {
526 + state.ignoreEmulatedEvents = true;
527 + }
528 + }
529 + }
530 + break;
531 + }
532 + }
533 + },
534 + onRootEvent(
535 + event: ReactDOMResponderEvent,
536 + context: ReactDOMResponderContext,
537 + props: TapProps,
538 + state: TapState,
539 + ): void {
540 + const nativeEvent: any = event.nativeEvent;
541 + const eventType = event.type;
542 + const hitTarget = getHitTarget(event, context, state);
543 +
544 + switch (eventType) {
545 + // MOVE
546 + case 'pointermove':
547 + case 'mousemove':
548 + case 'touchmove': {
549 + if (!hasPointerEvents) {
550 + if (eventType === 'mousemove' && state.ignoreEmulatedEvents) {
551 + return;
552 + }
553 + }
554 +
555 + if (state.isActive && isActivePointer(event, state)) {
556 + state.gestureState = createGestureState(context, props, state, event);
557 + if (context.isTargetWithinResponder(hitTarget)) {
558 + dispatchUpdate(context, props, state);
559 + } else {
560 + state.isActive = false;
561 + dispatchChange(context, props, state);
562 + dispatchCancel(context, props, state);
563 + }
564 + }
565 + break;
566 + }
567 +
568 + // END
569 + case 'pointerup':
570 + case 'mouseup':
571 + case 'touchend': {
572 + if (state.isActive && isActivePointer(event, state)) {
573 + if (state.buttons === buttonsEnum.middle) {
574 + // Remove the root events here as no 'click' event is dispatched
575 + // when this 'button' is pressed.
576 + removeRootEventTypes(context, state);
577 + }
578 +
579 + state.gestureState = createGestureState(context, props, state, event);
580 +
581 + if (context.isTargetWithinResponder(hitTarget)) {
582 + // Determine whether to call preventDefault on subsequent native events.
583 + if (isModifiedTap(event)) {
584 + state.shouldPreventClick = false;
585 + }
586 + dispatchEnd(context, props, state);
587 + } else {
588 + dispatchCancel(context, props, state);
589 + }
590 + state.isActive = false;
591 + dispatchChange(context, props, state);
592 + }
593 +
594 + if (!hasPointerEvents) {
595 + if (eventType === 'mouseup') {
596 + state.ignoreEmulatedEvents = false;
597 + }
598 + }
599 + break;
600 + }
601 +
602 + // CANCEL
603 + case 'contextmenu':
604 + case 'pointercancel':
605 + case 'touchcancel':
606 + case 'dragstart': {
607 + if (state.isActive && isActivePointer(event, state)) {
608 + state.gestureState = createGestureState(context, props, state, event);
609 + dispatchCancel(context, props, state);
610 + state.isActive = false;
611 + dispatchChange(context, props, state);
612 + }
613 + break;
614 + }
615 +
616 + // CANCEL
617 + case 'scroll': {
618 + if (
619 + state.isActive &&
620 + // We ignore incoming scroll events when using mouse events
621 + state.pointerType !== 'mouse' &&
622 + // If the scroll target is the document or if the pointer target
623 + // is within the 'scroll' target, then cancel the gesture
624 + context.isTargetWithinNode(state.responderTarget, nativeEvent.target)
625 + ) {
626 + state.gestureState = createGestureState(context, props, state, event);
627 + dispatchCancel(context, props, state);
628 + state.isActive = false;
629 + dispatchChange(context, props, state);
630 + }
631 + break;
632 + }
633 +
634 + case 'click': {
635 + if (state.shouldPreventClick) {
636 + nativeEvent.preventDefault();
637 + }
638 + removeRootEventTypes(context, state);
639 + break;
640 + }
641 + }
642 + },
643 + onUnmount(
644 + context: ReactDOMResponderContext,
645 + props: TapProps,
646 + state: TapState,
647 + ): void {
648 + removeRootEventTypes(context, state);
649 + if (state.isActive) {
650 + dispatchCancel(context, props, state);
651 + state.isActive = false;
652 + }
653 + },
654 +};
655 +
656 +export const TapResponder = React.unstable_createResponder(
657 + 'Tap',
658 + responderImpl,
659 +);
660 +
661 +export function useTap(props: TapProps): ReactEventResponderListener<any, any> {
662 + return React.unstable_useResponder(TapResponder, props);
663 +}
packages/react-events/src/dom/__tests__/Tap-test.internal.js new
+664
@@ -0,0 +1,664 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +import {
13 + buttonsType,
14 + createEventTarget,
15 + describeWithPointerEvent,
16 + setPointerEvent,
17 + testWithPointerType,
18 +} from '../testing-library';
19 +
20 +let React;
21 +let ReactFeatureFlags;
22 +let ReactDOM;
23 +let useTap;
24 +
25 +function initializeModules(hasPointerEvents) {
26 + jest.resetModules();
27 + setPointerEvent(hasPointerEvents);
28 + ReactFeatureFlags = require('shared/ReactFeatureFlags');
29 + ReactFeatureFlags.enableFlareAPI = true;
30 + React = require('react');
31 + ReactDOM = require('react-dom');
32 + useTap = require('react-events/tap').useTap;
33 +}
34 +
35 +const coordinatesInside = {x: 51, y: 51};
36 +const coordinatesOutside = {x: 49, y: 49};
37 +
38 +function tapAndMoveOutside({
39 + hasPointerEvents,
40 + pointerType,
41 + downTarget,
42 + upTarget,
43 +}) {
44 + downTarget.setBoundingClientRect({width: 100, height: 100, x: 50, y: 50});
45 + downTarget.pointerdown({pointerType, ...coordinatesInside});
46 + downTarget.pointermove({pointerType, ...coordinatesInside});
47 + // NOTE: this assumes the PointerEvent implementation calls
48 + // 'releasePointerCapture' for touch pointers
49 + if (!hasPointerEvents && pointerType === 'touch') {
50 + downTarget.pointermove({pointerType, ...coordinatesOutside});
51 + document.elementFromPoint = () => upTarget.node;
52 + } else {
53 + upTarget.pointermove({pointerType, ...coordinatesOutside});
54 + }
55 +}
56 +
57 +function tapAndReleaseOutside({
58 + hasPointerEvents,
59 + pointerType,
60 + downTarget,
61 + upTarget,
62 +}) {
63 + tapAndMoveOutside({hasPointerEvents, pointerType, downTarget, upTarget});
64 + if (!hasPointerEvents && pointerType === 'touch') {
65 + downTarget.pointerup({pointerType, ...coordinatesOutside});
66 + } else {
67 + upTarget.pointerup({pointerType, ...coordinatesOutside});
68 + }
69 +}
70 +
71 +describeWithPointerEvent('Tap responder', hasPointerEvents => {
72 + let container;
73 +
74 + beforeEach(() => {
75 + initializeModules(hasPointerEvents);
76 + container = document.createElement('div');
77 + document.body.appendChild(container);
78 + });
79 +
80 + afterEach(() => {
81 + ReactDOM.render(null, container);
82 + document.body.removeChild(container);
83 + container = null;
84 + });
85 +
86 + test('supports repeated use', () => {
87 + const ref = React.createRef();
88 + const Component = () => {
89 + const listener = useTap();
90 + return <button ref={ref} listeners={listener} />;
91 + };
92 + ReactDOM.render(<Component />, container);
93 +
94 + const target = createEventTarget(ref.current);
95 + function interact() {
96 + target.pointerdown();
97 + target.pointermove();
98 + target.pointerup();
99 + }
100 + expect(() => {
101 + interact();
102 + interact();
103 + }).not.toThrow();
104 + });
105 +
106 + describe('disabled', () => {
107 + let onTapStart, onTapChange, onTapUpdate, onTapCancel, onTapEnd, ref;
108 +
109 + beforeEach(() => {
110 + onTapStart = jest.fn();
111 + onTapChange = jest.fn();
112 + onTapUpdate = jest.fn();
113 + onTapCancel = jest.fn();
114 + onTapEnd = jest.fn();
115 + ref = React.createRef();
116 + const Component = () => {
117 + const listener = useTap({
118 + disabled: true,
119 + onTapStart,
120 + onTapChange,
121 + onTapUpdate,
122 + onTapCancel,
123 + onTapEnd,
124 + });
125 + return <div ref={ref} listeners={listener} />;
126 + };
127 + ReactDOM.render(<Component />, container);
128 + });
129 +
130 + test('does not call callbacks', () => {
131 + const target = createEventTarget(ref.current);
132 + target.pointerdown();
133 + target.pointerup();
134 + expect(onTapStart).not.toBeCalled();
135 + expect(onTapChange).not.toBeCalled();
136 + expect(onTapUpdate).not.toBeCalled();
137 + expect(onTapCancel).not.toBeCalled();
138 + expect(onTapEnd).not.toBeCalled();
139 + });
140 + });
141 +
142 + describe('onTapStart', () => {
143 + let onTapStart, ref;
144 +
145 + beforeEach(() => {
146 + onTapStart = jest.fn();
147 + ref = React.createRef();
148 + const Component = () => {
149 + const listener = useTap({onTapStart});
150 + return <div ref={ref} listeners={listener} />;
151 + };
152 + ReactDOM.render(<Component />, container);
153 + document.elementFromPoint = () => ref.current;
154 + });
155 +
156 + testWithPointerType('pointer down', pointerType => {
157 + const target = createEventTarget(ref.current);
158 + const nativeEvent = {
159 + pageX: 10,
160 + pageY: 10,
161 + pointerType,
162 + x: 10,
163 + y: 10,
164 + };
165 + target.pointerdown(nativeEvent);
166 + // 'pointerup' is only for the MouseEvent/TouchEvent fallback
167 + // implementation. We also dispatch 'pointerup' so that this test covers
168 + // the case where browsers dispatch an emulated mousedown (and mouseup)
169 + // event *after* a touch ends.
170 + target.pointerup(nativeEvent);
171 + expect(onTapStart).toHaveBeenCalledTimes(1);
172 + expect(onTapStart).toHaveBeenCalledWith(
173 + expect.objectContaining({
174 + altKey: false,
175 + buttons: 1,
176 + ctrlKey: false,
177 + height: pointerType === 'mouse' ? 1 : 23,
178 + metaKey: false,
179 + pageX: 10,
180 + pageY: 10,
181 + pointerType,
182 + pressure: pointerType === 'touch' ? 1 : 0.5,
183 + screenX: 10,
184 + screenY: 60,
185 + shiftKey: false,
186 + tangentialPressure: 0,
187 + target: target.node,
188 + tiltX: 0,
189 + tiltY: 0,
190 + timeStamp: expect.any(Number),
191 + twist: 0,
192 + type: 'tap:start',
193 + width: pointerType === 'mouse' ? 1 : 23,
194 + x: 10,
195 + y: 10,
196 + }),
197 + );
198 + });
199 +
200 + test('second pointer down', () => {
201 + const pointerType = 'touch';
202 + const target = createEventTarget(ref.current);
203 + const buttons = buttonsType.primary;
204 + target.pointerdown({buttons, pointerId: 1, pointerType});
205 + expect(onTapStart).toHaveBeenCalledTimes(1);
206 + if (hasPointerEvents) {
207 + target.pointerdown({buttons, pointerId: 2, pointerType});
208 + } else {
209 + // TouchEvents
210 + target.pointerdown([
211 + {pointerId: 1, pointerType},
212 + {pointerId: 2, pointerType},
213 + ]);
214 + }
215 + expect(onTapStart).toHaveBeenCalledTimes(1);
216 + });
217 +
218 + test('primary-button pointer down', () => {
219 + const pointerType = 'mouse';
220 + const buttons = buttonsType.primary;
221 + const target = createEventTarget(ref.current);
222 + target.pointerdown({buttons, pointerType});
223 + expect(onTapStart).toHaveBeenCalledTimes(1);
224 + expect(onTapStart).toHaveBeenCalledWith(
225 + expect.objectContaining({buttons, pointerType}),
226 + );
227 + });
228 +
229 + test('middle-button pointer down', () => {
230 + const pointerType = 'mouse';
231 + const buttons = buttonsType.middle;
232 + const target = createEventTarget(ref.current);
233 + target.pointerdown({buttons, pointerType});
234 + expect(onTapStart).toHaveBeenCalledTimes(1);
235 + expect(onTapStart).toHaveBeenCalledWith(
236 + expect.objectContaining({buttons, pointerType}),
237 + );
238 + });
239 +
240 + test('unsupported buttons', () => {
241 + const target = createEventTarget(ref.current);
242 + // right-click
243 + target.pointerdown({buttons: buttonsType.secondary});
244 + target.pointerup();
245 + // pen eraser
246 + target.pointerdown({buttons: buttonsType.eraser});
247 + target.pointerup();
248 + expect(onTapStart).toHaveBeenCalledTimes(0);
249 + });
250 + });
251 +
252 + describe('onTapEnd', () => {
253 + let onTapEnd, ref;
254 +
255 + beforeEach(() => {
256 + onTapEnd = jest.fn();
257 + ref = React.createRef();
258 + const Component = () => {
259 + const listener = useTap({onTapEnd});
260 + return <div ref={ref} listeners={listener} />;
261 + };
262 + ReactDOM.render(<Component />, container);
263 + document.elementFromPoint = () => ref.current;
264 + });
265 +
266 + testWithPointerType('pointer up', pointerType => {
267 + const target = createEventTarget(ref.current);
268 + target.pointerdown({pointerType});
269 + target.pointerup({
270 + pageX: 10,
271 + pageY: 10,
272 + pointerType,
273 + x: 10,
274 + y: 10,
275 + });
276 + expect(onTapEnd).toHaveBeenCalledTimes(1);
277 + expect(onTapEnd).toHaveBeenCalledWith(
278 + expect.objectContaining({
279 + altKey: false,
280 + buttons: 1,
281 + ctrlKey: false,
282 + height: pointerType === 'mouse' ? 1 : 23,
283 + metaKey: false,
284 + pageX: 10,
285 + pageY: 10,
286 + pointerType,
287 + pressure: 0,
288 + screenX: 10,
289 + screenY: 60,
290 + shiftKey: false,
291 + tangentialPressure: 0,
292 + target: target.node,
293 + tiltX: 0,
294 + tiltY: 0,
295 + timeStamp: expect.any(Number),
296 + twist: 0,
297 + type: 'tap:end',
298 + width: pointerType === 'mouse' ? 1 : 23,
299 + x: 10,
300 + y: 10,
301 + }),
302 + );
303 + });
304 +
305 + test('primary-button pointer up', () => {
306 + const pointerType = 'mouse';
307 + const buttons = buttonsType.primary;
308 + const target = createEventTarget(ref.current);
309 + target.pointerdown({buttons, pointerType});
310 + target.pointerup({pointerType});
311 + expect(onTapEnd).toHaveBeenCalledTimes(1);
312 + expect(onTapEnd).toHaveBeenCalledWith(
313 + expect.objectContaining({buttons, pointerType}),
314 + );
315 + });
316 +
317 + test('middle-button pointer up', () => {
318 + const pointerType = 'mouse';
319 + const buttons = buttonsType.middle;
320 + const target = createEventTarget(ref.current);
321 + target.pointerdown({buttons, pointerType});
322 + target.pointerup({pointerType});
323 + expect(onTapEnd).toHaveBeenCalledTimes(1);
324 + expect(onTapEnd).toHaveBeenCalledWith(
325 + expect.objectContaining({buttons, pointerType}),
326 + );
327 + });
328 +
329 + testWithPointerType('zero-dimension hit rect', pointerType => {
330 + const targetRef = React.createRef();
331 + const innerRef = React.createRef();
332 +
333 + const Component = () => {
334 + const listener = useTap({onTapEnd});
335 + return (
336 + <div ref={targetRef} listeners={listener}>
337 + <button ref={innerRef} />
338 + </div>
339 + );
340 + };
341 + ReactDOM.render(<Component />, container);
342 + document.elementFromPoint = () => innerRef.current;
343 +
344 + const target = createEventTarget(targetRef.current);
345 + target.setBoundingClientRect({x: 0, y: 0, width: 0, height: 0});
346 + const innerTarget = createEventTarget(innerRef.current);
347 + innerTarget.pointerdown({pointerType});
348 + innerTarget.pointerup({pointerType});
349 + expect(onTapEnd).toBeCalled();
350 + });
351 +
352 + testWithPointerType('pointer up outside target', pointerType => {
353 + const downTarget = createEventTarget(ref.current);
354 + const upTarget = createEventTarget(container);
355 + tapAndReleaseOutside({
356 + hasPointerEvents,
357 + downTarget,
358 + upTarget,
359 + pointerType,
360 + });
361 + expect(onTapEnd).not.toBeCalled();
362 + });
363 + });
364 +
365 + describe('onTapUpdate', () => {
366 + let onTapUpdate, ref;
367 + const rect = {x: 0, y: 0, width: 100, height: 100};
368 + const coordinates = {x: 10, y: 10};
369 +
370 + beforeEach(() => {
371 + onTapUpdate = jest.fn();
372 + ref = React.createRef();
373 + const Component = () => {
374 + const listener = useTap({onTapUpdate});
375 + return <div ref={ref} listeners={listener} />;
376 + };
377 + ReactDOM.render(<Component />, container);
378 + document.elementFromPoint = () => ref.current;
379 + });
380 +
381 + testWithPointerType('requires activation', pointerType => {
382 + const target = createEventTarget(ref.current);
383 + target.setBoundingClientRect(rect);
384 + target.pointerhover({pointerType, ...coordinates});
385 + target.pointermove({pointerType, ...coordinates});
386 + expect(onTapUpdate).not.toBeCalled();
387 + });
388 +
389 + testWithPointerType('pointer move', pointerType => {
390 + const target = createEventTarget(ref.current);
391 + target.setBoundingClientRect(rect);
392 + target.pointerdown({pointerType});
393 +
394 + target.pointermove({pointerType, x: 10, y: 10});
395 + expect(onTapUpdate).toHaveBeenCalledWith(
396 + expect.objectContaining({
397 + altKey: false,
398 + buttons: 1,
399 + ctrlKey: false,
400 + height: pointerType === 'mouse' ? 1 : 23,
401 + metaKey: false,
402 + pageX: 10,
403 + pageY: 10,
404 + pointerType,
405 + pressure: pointerType === 'touch' ? 1 : 0.5,
406 + screenX: 10,
407 + screenY: 60,
408 + shiftKey: false,
409 + tangentialPressure: 0,
410 + target: target.node,
411 + tiltX: 0,
412 + tiltY: 0,
413 + timeStamp: expect.any(Number),
414 + twist: 0,
415 + type: 'tap:update',
416 + width: pointerType === 'mouse' ? 1 : 23,
417 + x: 10,
418 + y: 10,
419 + }),
420 + );
421 +
422 + target.pointermove({pointerType, x: 20, y: 20});
423 + expect(onTapUpdate).toHaveBeenCalledWith(
424 + expect.objectContaining({pointerType, x: 20, y: 20}),
425 + );
426 +
427 + expect(onTapUpdate).toHaveBeenCalledTimes(2);
428 + });
429 +
430 + testWithPointerType('pointer moves outside target', pointerType => {
431 + const target = createEventTarget(ref.current);
432 + const containerTarget = createEventTarget(container);
433 + target.setBoundingClientRect(rect);
434 + target.pointerdown({pointerType});
435 + target.pointermove({pointerType, x: 10, y: 10});
436 + expect(onTapUpdate).toHaveBeenCalledTimes(1);
437 +
438 + // NOTE: this assumes the PointerEvent implementation calls
439 + // 'releasePointerCapture' for touch pointers
440 + if (!hasPointerEvents && pointerType === 'touch') {
441 + document.elementFromPoint = () => containerTarget.node;
442 + target.pointermove({pointerType, x: 101, y: 101});
443 + } else {
444 + containerTarget.pointermove({pointerType, x: 101, y: 101});
445 + }
446 +
447 + // No extra 'onTapUpdate' calls when the pointer is outside the target
448 + expect(onTapUpdate).toHaveBeenCalledTimes(1);
449 + });
450 + });
451 +
452 + describe('onTapChange', () => {
453 + let onTapChange, ref;
454 +
455 + beforeEach(() => {
456 + onTapChange = jest.fn();
457 + ref = React.createRef();
458 + const Component = () => {
459 + const listener = useTap({onTapChange});
460 + return <div ref={ref} listeners={listener} />;
461 + };
462 + ReactDOM.render(<Component />, container);
463 + });
464 +
465 + testWithPointerType('pointer down/up', pointerType => {
466 + const target = createEventTarget(ref.current);
467 + target.pointerdown({pointerType});
468 + expect(onTapChange).toHaveBeenCalledTimes(1);
469 + expect(onTapChange).toHaveBeenCalledWith(true);
470 + target.pointerup({pointerType});
471 + expect(onTapChange).toHaveBeenCalledTimes(2);
472 + expect(onTapChange).toHaveBeenCalledWith(false);
473 + });
474 +
475 + testWithPointerType('pointer cancel', pointerType => {
476 + const target = createEventTarget(ref.current);
477 + target.pointerdown({pointerType});
478 + expect(onTapChange).toHaveBeenCalledTimes(1);
479 + expect(onTapChange).toHaveBeenCalledWith(true);
480 + target.pointercancel({pointerType});
481 + expect(onTapChange).toHaveBeenCalledTimes(2);
482 + expect(onTapChange).toHaveBeenCalledWith(false);
483 + });
484 +
485 + testWithPointerType('pointer move outside target', pointerType => {
486 + const downTarget = createEventTarget(ref.current);
487 + const upTarget = createEventTarget(container);
488 + tapAndMoveOutside({
489 + hasPointerEvents,
490 + downTarget,
491 + upTarget,
492 + pointerType,
493 + });
494 + expect(onTapChange).toHaveBeenCalledTimes(2);
495 + });
496 + });
497 +
498 + describe('onTapCancel', () => {
499 + let onTapCancel, parentRef, ref, siblingRef;
500 +
501 + beforeEach(() => {
502 + onTapCancel = jest.fn();
503 + parentRef = React.createRef();
504 + ref = React.createRef();
505 + siblingRef = React.createRef();
506 + const Component = () => {
507 + const listener = useTap({onTapCancel});
508 + return (
509 + <div ref={parentRef}>
510 + <div ref={ref} listeners={listener} />
511 + <span ref={siblingRef} />
512 + </div>
513 + );
514 + };
515 + ReactDOM.render(<Component />, container);
516 + });
517 +
518 + testWithPointerType('pointer cancel', pointerType => {
519 + const target = createEventTarget(ref.current);
520 + target.pointerdown({pointerType});
521 + target.pointercancel({pointerType});
522 + expect(onTapCancel).toHaveBeenCalledTimes(1);
523 + expect(onTapCancel).toHaveBeenCalledWith(
524 + expect.objectContaining({
525 + altKey: false,
526 + buttons: buttonsType.primary,
527 + ctrlKey: false,
528 + height: 1,
529 + metaKey: false,
530 + pageX: 0,
531 + pageY: 0,
532 + pointerType,
533 + pressure: 0,
534 + screenX: 0,
535 + screenY: 0,
536 + shiftKey: false,
537 + tangentialPressure: 0,
538 + target: target.node,
539 + tiltX: 0,
540 + tiltY: 0,
541 + timeStamp: expect.any(Number),
542 + twist: 0,
543 + type: 'tap:cancel',
544 + width: 1,
545 + x: 0,
546 + y: 0,
547 + }),
548 + );
549 + });
550 +
551 + test('long press context menu', () => {
552 + const target = createEventTarget(ref.current);
553 + target.contextmenu({}, {pointerType: 'touch'});
554 + expect(onTapCancel).toHaveBeenCalledTimes(1);
555 + });
556 +
557 + test('parent scroll (non-mouse)', () => {
558 + const target = createEventTarget(ref.current);
559 + const parentTarget = createEventTarget(parentRef.current);
560 + target.pointerdown({pointerType: 'touch'});
561 + parentTarget.scroll();
562 + expect(onTapCancel).toHaveBeenCalledTimes(1);
563 + });
564 +
565 + test('sibling scroll', () => {
566 + const target = createEventTarget(ref.current);
567 + const siblingTarget = createEventTarget(siblingRef.current);
568 + target.pointerdown();
569 + siblingTarget.scroll();
570 + expect(onTapCancel).not.toBeCalled();
571 + });
572 +
573 + test('document scroll (non-mouse)', () => {
574 + const target = createEventTarget(ref.current);
575 + const documentTarget = createEventTarget(document);
576 + target.pointerdown({pointerType: 'touch'});
577 + documentTarget.scroll();
578 + expect(onTapCancel).toHaveBeenCalledTimes(1);
579 + });
580 +
581 + // Scroll on an element not managed by React
582 + test('root container scroll (non-mouse)', () => {
583 + const target = createEventTarget(ref.current);
584 + const containerTarget = createEventTarget(container);
585 + target.pointerdown({pointerType: 'touch'});
586 + containerTarget.scroll();
587 + expect(onTapCancel).toHaveBeenCalledTimes(1);
588 + });
589 +
590 + testWithPointerType('pointer move outside target', pointerType => {
591 + const downTarget = createEventTarget(ref.current);
592 + const upTarget = createEventTarget(container);
593 + tapAndMoveOutside({
594 + hasPointerEvents,
595 + downTarget,
596 + upTarget,
597 + pointerType,
598 + });
599 + expect(onTapCancel).toBeCalled();
600 + });
601 + });
602 +
603 + describe('preventDefault', () => {
604 + let onTapEnd, ref, innerRef, preventDefault, remount;
605 +
606 + beforeEach(() => {
607 + remount = function(shouldPreventDefault) {
608 + onTapEnd = jest.fn();
609 + preventDefault = jest.fn();
610 + ref = React.createRef();
611 + innerRef = React.createRef();
612 + const Component = () => {
613 + const listener = useTap({
614 + onTapEnd,
615 + preventDefault: shouldPreventDefault,
616 + });
617 + return (
618 + <a href="#" ref={ref} listeners={listener}>
619 + <div ref={innerRef} />
620 + </a>
621 + );
622 + };
623 + ReactDOM.render(<Component />, container);
624 + };
625 + remount();
626 + });
627 +
628 + test('prevents native behavior by default', () => {
629 + const target = createEventTarget(ref.current);
630 + target.pointerdown();
631 + target.pointerup({preventDefault});
632 + expect(preventDefault).toBeCalled();
633 + expect(onTapEnd).toBeCalled();
634 + });
635 +
636 + test('prevents native behaviour by default (inner target)', () => {
637 + const innerTarget = createEventTarget(innerRef.current);
638 + innerTarget.pointerdown();
639 + innerTarget.pointerup({preventDefault});
640 + expect(preventDefault).toBeCalled();
641 + expect(onTapEnd).toBeCalled();
642 + });
643 +
644 + test('allows native behaviour by default (modifier keys)', () => {
645 + ['metaKey', 'ctrlKey', 'shiftKey'].forEach(modifierKey => {
646 + const target = createEventTarget(ref.current);
647 + target.pointerdown({[modifierKey]: true});
648 + target.pointerup({[modifierKey]: true, preventDefault});
649 + expect(preventDefault).not.toBeCalled();
650 + expect(onTapEnd).toBeCalled();
651 + });
652 + });
653 +
654 + test('allows native behaviour if false', () => {
655 + remount(false);
656 +
657 + const target = createEventTarget(ref.current);
658 + target.pointerdown();
659 + target.pointerup({preventDefault});
660 + expect(preventDefault).not.toBeCalled();
661 + expect(onTapEnd).toBeCalled();
662 + });
663 + });
664 +});
packages/react-events/src/dom/shared/index.js new
+47
@@ -0,0 +1,47 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +import {DiscreteEvent, UserBlockingEvent} from 'shared/ReactTypes';
13 +import type {
14 + ReactDOMResponderContext,
15 + ReactDOMResponderEvent,
16 +} from 'shared/ReactDOMTypes';
17 +
18 +export const hasPointerEvents =
19 + typeof window !== 'undefined' && window.PointerEvent !== undefined;
20 +
21 +export const isMac =
22 + typeof window !== 'undefined' && window.navigator != null
23 + ? /^Mac/.test(window.navigator.platform)
24 + : false;
25 +
26 +export const buttonsEnum = {
27 + none: 0,
28 + primary: 1,
29 + secondary: 2,
30 + middle: 4,
31 +};
32 +
33 +export function dispatchDiscreteEvent(
34 + context: ReactDOMResponderContext,
35 + payload: ReactDOMResponderEvent,
36 + callback: any => void,
37 +) {
38 + context.dispatchEvent(payload, callback, DiscreteEvent);
39 +}
40 +
41 +export function dispatchUserBlockingEvent(
42 + context: ReactDOMResponderContext,
43 + payload: ReactDOMResponderEvent,
44 + callback: any => void,
45 +) {
46 + context.dispatchEvent(payload, callback, UserBlockingEvent);
47 +}
packages/react-events/src/dom/testing-library/domEnvironment.js
+12 -4
@@ -20,10 +20,18 @@ export function hasPointerEvent() {
20 }
21
22 export function setPointerEvent(bool) {
23 - const mock = bool ? emptyFunction : undefined;
24 - global.PointerEvent = mock;
25 - global.HTMLElement.prototype.setPointerCapture = mock;
26 - global.HTMLElement.prototype.releasePointerCapture = mock;
23 + const pointerCaptureFn = name => id => {
24 + if (typeof id !== 'number') {
25 + console.error(`A pointerId must be passed to "${name}"`);
26 + }
27 + };
28 + global.PointerEvent = bool ? emptyFunction : undefined;
29 + global.HTMLElement.prototype.setPointerCapture = bool
30 + ? pointerCaptureFn('setPointerCapture')
31 + : undefined;
32 + global.HTMLElement.prototype.releasePointerCapture = bool
33 + ? pointerCaptureFn('releasePointerCapture')
34 + : undefined;
35 }
36
37 /**
packages/react-events/src/dom/testing-library/domEventSequences.js
+4 -2
@@ -153,10 +153,11 @@ export function pointermove(target, payload) {
153 }
154 }
155
156 -export function pointerup(target, defaultPayload) {
156 +export function pointerup(target, defaultPayload = {}) {
157 const dispatch = arg => target.dispatchEvent(arg);
158 const pointerType = getPointerType(defaultPayload);
159 - const payload = {buttons: buttonsType.none, ...defaultPayload};
159 + // eslint-disable-next-line no-unused-vars
160 + const {buttons, ...payload} = defaultPayload;
161
162 if (pointerType === 'mouse') {
163 if (hasPointerEvent()) {
@@ -174,6 +175,7 @@ export function pointerup(target, defaultPayload) {
175 dispatch(domEvents.touchend(payload));
176 dispatch(domEvents.mouseover(payload));
177 dispatch(domEvents.mousemove(payload));
178 + // NOTE: the value of 'buttons' for 'mousedown' must not be 0
179 dispatch(domEvents.mousedown(payload));
180 if (document.activeElement !== target) {
181 dispatch(domEvents.focus());
packages/react-events/src/dom/testing-library/domEvents.js
+24 -17
@@ -245,6 +245,7 @@ function createTouchEvent(type, payload) {
245 pageX,
246 pageY,
247 pointerId = 1,
248 + pressure = 1,
249 twist = 0,
250 width = defaultPointerSize,
251 x = 0,
@@ -253,7 +254,7 @@ function createTouchEvent(type, payload) {
254 return {
255 clientX: x,
256 clientY: y,
256 - force: 1,
257 + force: pressure,
258 identifier: pointerId,
259 pageX: pageX || x,
260 pageY: pageY || y,
@@ -297,6 +298,28 @@ export function click(payload) {
298 return createMouseEvent('click', payload);
299 }
300
301 +export function contextmenu(payload) {
302 + return createMouseEvent('contextmenu', {
303 + ...payload,
304 + detail: 0,
305 + });
306 +}
307 +
308 +export function dragstart(payload) {
309 + return createMouseEvent('dragstart', {
310 + ...payload,
311 + detail: 0,
312 + });
313 +}
314 +
315 +export function focus({relatedTarget} = {}) {
316 + return createEvent('focus', {relatedTarget});
317 +}
318 +
319 +export function scroll() {
320 + return createEvent('scroll');
321 +}
322 +
323 export function virtualclick(payload) {
324 return createMouseEvent('click', {
325 ...payload,
@@ -314,22 +337,6 @@ export function virtualclick(payload) {
337 });
338 }
339
317 -export function contextmenu(payload) {
318 - return createMouseEvent('contextmenu', payload);
319 -}
320 -
321 -export function dragstart(payload) {
322 - return createMouseEvent('dragstart', payload);
323 -}
324 -
325 -export function focus({relatedTarget} = {}) {
326 - return createEvent('focus', {relatedTarget});
327 -}
328 -
329 -export function scroll() {
330 - return createEvent('scroll');
331 -}
332 -
340 /**
341 * Key events
342 */
packages/react-events/src/dom/testing-library/index.js
+4 -4
@@ -35,6 +35,9 @@ const createEventTarget = node => ({
35 focus(payload) {
36 node.dispatchEvent(domEvents.focus(payload));
37 },
38 + scroll(payload) {
39 + node.dispatchEvent(domEvents.scroll(payload));
40 + },
41 /**
42 * KeyboardEvent abstraction.
43 */
@@ -47,9 +50,6 @@ const createEventTarget = node => ({
50 virtualclick(payload) {
51 node.dispatchEvent(domEvents.virtualclick(payload));
52 },
50 - scroll(payload) {
51 - node.dispatchEvent(domEvents.scroll(payload));
52 - },
53 /**
54 * PointerEvent abstraction.
55 * Dispatches the expected sequence of PointerEvents, MouseEvents, and
@@ -75,7 +75,7 @@ const createEventTarget = node => ({
75 pointerenter(payload) {
76 domEventSequences.pointerenter(node, payload);
77 },
78 - // node dispatches exit & out events
78 + // node dispatches exit & leave events
79 pointerexit(payload) {
80 domEventSequences.pointerexit(node, payload);
81 },
packages/react-events/tap.js new
+12
@@ -0,0 +1,12 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +'use strict';
11 +
12 +module.exports = require('./src/dom/Tap');
packages/shared/ReactDOMTypes.js
+3 -4
@@ -28,7 +28,6 @@ export type ReactDOMResponderEvent = {
28 nativeEvent: AnyNativeEvent,
29 passive: boolean,
30 passiveSupported: boolean,
31 - pointerId: null | number,
31 pointerType: PointerType,
32 target: Element | Document,
33 type: string,
@@ -56,11 +55,11 @@ export type ReactDOMResponderContext = {
55 eventPriority: EventPriority,
56 ) => void,
57 isTargetWithinNode: (
59 - childTarget: Element | Document,
58 + childTarget: null | Element | Document,
59 parentTarget: Element | Document,
60 ) => boolean,
62 - isTargetWithinResponder: (Element | Document) => boolean,
63 - isTargetWithinResponderScope: (Element | Document) => boolean,
61 + isTargetWithinResponder: (null | Element | Document) => boolean,
62 + isTargetWithinResponderScope: (null | Element | Document) => boolean,
63 addRootEventTypes: (rootEventTypes: Array<string>) => void,
64 removeRootEventTypes: (rootEventTypes: Array<string>) => void,
65 setTimeout: (func: () => void, timeout: number) => number,
scripts/rollup/bundles.js
+15
@@ -629,6 +629,21 @@ const bundles = [
629 global: 'ReactEventsSwipe',
630 externals: ['react'],
631 },
632 +
633 + {
634 + bundleTypes: [
635 + UMD_DEV,
636 + UMD_PROD,
637 + NODE_DEV,
638 + NODE_PROD,
639 + FB_WWW_DEV,
640 + FB_WWW_PROD,
641 + ],
642 + moduleType: NON_FIBER_RENDERER,
643 + entry: 'react-events/tap',
644 + global: 'ReactEventsTap',
645 + externals: ['react'],
646 + },
647 ];
648
649 // Based on deep-freeze by substack (public domain)