@samitouri / QOS-React-2 / commits / 45473c94cd

React events: Press event fixes (#15386)

1. Fix hiding context menu for longpress via touch. 2. Fix scrolling of viewport for longpress via spacebar key. 3. Add tests for anchor-related behaviour and preventDefault. 4. Add a deactivation delay for forced activation 5. Add pointerType to Press events. NOTE: this currently extends pointerType to include `keyboard`. NOTE: React Native doesn't have a deactivation delay for forced activation, but this is possibly because of the async bridge meaning that the events aren't dispatched sync.

Nicolas Gallagher committed Apr 11, 2019 at 13:20 UTC 45473c94cdb5d5d9642e5cde55ed3a5795720801
3 files changed +240 -27
packages/react-events/src/Hover.js
+2 -1
@@ -72,8 +72,9 @@ function dispatchHoverChangeEvent(
72 props: HoverProps,
73 state: HoverState,
74 ): void {
75 + const bool = state.isActiveHovered;
76 const listener = () => {
76 - props.onHoverChange(state.isActiveHovered);
77 + props.onHoverChange(bool);
78 };
79 const syntheticEvent = createHoverEvent(
80 'hoverchange',
packages/react-events/src/Press.js
+83 -21
@@ -36,6 +36,8 @@ type PressProps = {
36 stopPropagation: boolean,
37 };
38
39 +type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch';
40 +
41 type PressState = {
42 didDispatchEvent: boolean,
43 isActivePressed: boolean,
@@ -45,6 +47,7 @@ type PressState = {
47 isPressed: boolean,
48 isPressWithinResponderRegion: boolean,
49 longPressTimeout: null | Symbol,
50 + pointerType: PointerType,
51 pressTarget: null | Element | Document,
52 pressEndTimeout: null | Symbol,
53 pressStartTimeout: null | Symbol,
@@ -70,6 +73,7 @@ type PressEvent = {|
73 listener: PressEvent => void,
74 target: Element | Document,
75 type: PressEventType,
76 + pointerType: PointerType,
77 |};
78
79 const DEFAULT_PRESS_END_DELAY_MS = 0;
@@ -85,9 +89,10 @@ const DEFAULT_PRESS_RETENTION_OFFSET = {
89 const targetEventTypes = [
90 {name: 'click', passive: false},
91 {name: 'keydown', passive: false},
92 + {name: 'keypress', passive: false},
93 + {name: 'contextmenu', passive: false},
94 'pointerdown',
95 'pointercancel',
90 - 'contextmenu',
96 ];
97 const rootEventTypes = [
98 {name: 'keyup', passive: false},
@@ -110,11 +115,13 @@ function createPressEvent(
115 type: PressEventType,
116 target: Element | Document,
117 listener: PressEvent => void,
118 + pointerType: PointerType,
119 ): PressEvent {
120 return {
121 listener,
122 target,
123 type,
124 + pointerType,
125 };
126 }
127
@@ -125,7 +132,8 @@ function dispatchEvent(
132 listener: (e: Object) => void,
133 ): void {
134 const target = ((state.pressTarget: any): Element | Document);
128 - const syntheticEvent = createPressEvent(name, target, listener);
135 + const pointerType = state.pointerType;
136 + const syntheticEvent = createPressEvent(name, target, listener, pointerType);
137 context.dispatchEvent(syntheticEvent, {
138 discrete: true,
139 });
@@ -137,8 +145,9 @@ function dispatchPressChangeEvent(
145 props: PressProps,
146 state: PressState,
147 ): void {
148 + const bool = state.isActivePressed;
149 const listener = () => {
141 - props.onPressChange(state.isActivePressed);
150 + props.onPressChange(bool);
151 };
152 dispatchEvent(context, state, 'presschange', listener);
153 }
@@ -148,8 +157,9 @@ function dispatchLongPressChangeEvent(
157 props: PressProps,
158 state: PressState,
159 ): void {
160 + const bool = state.isLongPressed;
161 const listener = () => {
152 - props.onLongPressChange(state.isLongPressed);
162 + props.onLongPressChange(bool);
163 };
164 dispatchEvent(context, state, 'longpresschange', listener);
165 }
@@ -251,6 +261,7 @@ function dispatchPressEndEvents(
261 state: PressState,
262 ): void {
263 const wasActivePressStart = state.isActivePressStart;
264 + let activationWasForced = false;
265
266 state.isActivePressStart = false;
267 state.isPressed = false;
@@ -267,13 +278,17 @@ function dispatchPressEndEvents(
278 if (state.isPressWithinResponderRegion) {
279 // if we haven't yet activated (due to delays), activate now
280 activate(context, props, state);
281 + activationWasForced = true;
282 }
283 }
284
285 if (state.isActivePressed) {
286 const delayPressEnd = calculateDelayMS(
287 props.delayPressEnd,
276 - 0,
288 + // if activation and deactivation occur during the same event there's no
289 + // time for visual user feedback therefore a small delay is added before
290 + // deactivating.
291 + activationWasForced ? 10 : 0,
292 DEFAULT_PRESS_END_DELAY_MS,
293 );
294 if (delayPressEnd > 0) {
@@ -338,6 +353,23 @@ function calculateResponderRegion(target, props) {
353 };
354 }
355
356 +function getPointerType(nativeEvent: any) {
357 + const {type, pointerType} = nativeEvent;
358 + if (pointerType != null) {
359 + return pointerType;
360 + }
361 + if (type.indexOf('mouse') > -1) {
362 + return 'mouse';
363 + }
364 + if (type.indexOf('touch') > -1) {
365 + return 'touch';
366 + }
367 + if (type.indexOf('key') > -1) {
368 + return 'keyboard';
369 + }
370 + return '';
371 +}
372 +
373 function isPressWithinResponderRegion(
374 nativeEvent: $PropertyType<ReactResponderEvent, 'nativeEvent'>,
375 state: PressState,
@@ -377,6 +409,7 @@ const PressResponder = {
409 isPressed: false,
410 isPressWithinResponderRegion: true,
411 longPressTimeout: null,
412 + pointerType: '',
413 pressEndTimeout: null,
414 pressStartTimeout: null,
415 pressTarget: null,
@@ -403,10 +436,10 @@ const PressResponder = {
436 !context.hasOwnership() &&
437 !state.shouldSkipMouseAfterTouch
438 ) {
406 - if (
407 - (nativeEvent: any).pointerType === 'mouse' ||
408 - type === 'mousedown'
409 - ) {
439 + const pointerType = getPointerType(nativeEvent);
440 + state.pointerType = pointerType;
441 +
442 + if (pointerType === 'mouse' || type === 'mousedown') {
443 if (
444 // Ignore right- and middle-clicks
445 nativeEvent.button === 1 ||
@@ -436,6 +469,9 @@ const PressResponder = {
469 return;
470 }
471
472 + const pointerType = getPointerType(nativeEvent);
473 + state.pointerType = pointerType;
474 +
475 if (state.responderRegion == null) {
476 let currentTarget = (target: any);
477 while (
@@ -470,6 +506,9 @@ const PressResponder = {
506 return;
507 }
508
509 + const pointerType = getPointerType(nativeEvent);
510 + state.pointerType = pointerType;
511 +
512 const wasLongPressed = state.isLongPressed;
513
514 dispatchPressEndEvents(context, props, state);
@@ -506,6 +545,8 @@ const PressResponder = {
545 state.isAnchorTouched = true;
546 return;
547 }
548 + const pointerType = getPointerType(nativeEvent);
549 + state.pointerType = pointerType;
550 state.pressTarget = target;
551 state.isPressWithinResponderRegion = true;
552 dispatchPressStartEvents(context, props, state);
@@ -519,6 +560,9 @@ const PressResponder = {
560 return;
561 }
562 if (state.isPressed) {
563 + const pointerType = getPointerType(nativeEvent);
564 + state.pointerType = pointerType;
565 +
566 const wasLongPressed = state.isLongPressed;
567
568 dispatchPressEndEvents(context, props, state);
@@ -556,20 +600,24 @@ const PressResponder = {
600 * Keyboard interaction support
601 * TODO: determine UX for metaKey + validKeyPress interactions
602 */
559 - case 'keydown': {
603 + case 'keydown':
604 + case 'keypress': {
605 if (
561 - !state.isPressed &&
562 - !state.isLongPressed &&
606 !context.hasOwnership() &&
607 isValidKeyPress((nativeEvent: any).key)
608 ) {
566 - // Prevent spacebar press from scrolling the window
567 - if ((nativeEvent: any).key === ' ') {
568 - (nativeEvent: any).preventDefault();
609 + if (state.isPressed) {
610 + // Prevent spacebar press from scrolling the window
611 + if ((nativeEvent: any).key === ' ') {
612 + (nativeEvent: any).preventDefault();
613 + }
614 + } else {
615 + const pointerType = getPointerType(nativeEvent);
616 + state.pointerType = pointerType;
617 + state.pressTarget = target;
618 + dispatchPressStartEvents(context, props, state);
619 + context.addRootEventTypes(target.ownerDocument, rootEventTypes);
620 }
570 - state.pressTarget = target;
571 - dispatchPressStartEvents(context, props, state);
572 - context.addRootEventTypes(target.ownerDocument, rootEventTypes);
621 }
622 break;
623 }
@@ -593,7 +641,6 @@ const PressResponder = {
641 break;
642 }
643
596 - case 'contextmenu':
644 case 'pointercancel':
645 case 'scroll':
646 case 'touchcancel': {
@@ -608,14 +655,29 @@ const PressResponder = {
655 case 'click': {
656 if (isAnchorTagElement(target)) {
657 const {ctrlKey, metaKey, shiftKey} = ((nativeEvent: any): MouseEvent);
658 + // Check "open in new window/tab" and "open context menu" key modifiers
659 const preventDefault = props.preventDefault;
612 - // Check "open in new window/tab" key modifiers
613 - if (preventDefault !== false && !shiftKey && !ctrlKey && !metaKey) {
660 + if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) {
661 (nativeEvent: any).preventDefault();
662 }
663 }
664 + break;
665 + }
666 +
667 + case 'contextmenu': {
668 + if (state.isPressed) {
669 + if (props.preventDefault !== false) {
670 + (nativeEvent: any).preventDefault();
671 + } else {
672 + state.shouldSkipMouseAfterTouch = false;
673 + dispatchPressEndEvents(context, props, state);
674 + context.removeRootEventTypes(rootEventTypes);
675 + }
676 + }
677 + break;
678 }
679 }
680 +
681 if (state.didDispatchEvent) {
682 const shouldStopPropagation =
683 props.stopPropagation === undefined ? true : props.stopPropagation;
packages/react-events/src/__tests__/Press-test.internal.js
+155 -5
@@ -70,8 +70,13 @@ describe('Event responder: Press', () => {
70 });
71
72 it('is called after "pointerdown" event', () => {
73 - ref.current.dispatchEvent(createPointerEvent('pointerdown'));
73 + ref.current.dispatchEvent(
74 + createPointerEvent('pointerdown', {pointerType: 'pen'}),
75 + );
76 expect(onPressStart).toHaveBeenCalledTimes(1);
77 + expect(onPressStart).toHaveBeenCalledWith(
78 + expect.objectContaining({pointerType: 'pen', type: 'pressstart'}),
79 + );
80 });
81
82 it('ignores browser emulated "mousedown" event', () => {
@@ -85,13 +90,20 @@ describe('Event responder: Press', () => {
90 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'}));
91 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'}));
92 expect(onPressStart).toHaveBeenCalledTimes(1);
93 + expect(onPressStart).toHaveBeenCalledWith(
94 + expect.objectContaining({pointerType: 'keyboard', type: 'pressstart'}),
95 + );
96 });
97
98 it('is called once after "keydown" events for Spacebar', () => {
99 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '}));
100 + ref.current.dispatchEvent(createKeyboardEvent('keypress', {key: ' '}));
101 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '}));
93 - ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '}));
102 + ref.current.dispatchEvent(createKeyboardEvent('keypress', {key: ' '}));
103 expect(onPressStart).toHaveBeenCalledTimes(1);
104 + expect(onPressStart).toHaveBeenCalledWith(
105 + expect.objectContaining({pointerType: 'keyboard', type: 'pressstart'}),
106 + );
107 });
108
109 it('is not called after "keydown" for other keys', () => {
@@ -103,10 +115,16 @@ describe('Event responder: Press', () => {
115 it('is called after "mousedown" event', () => {
116 ref.current.dispatchEvent(createPointerEvent('mousedown'));
117 expect(onPressStart).toHaveBeenCalledTimes(1);
118 + expect(onPressStart).toHaveBeenCalledWith(
119 + expect.objectContaining({pointerType: 'mouse', type: 'pressstart'}),
120 + );
121 });
122 it('is called after "touchstart" event', () => {
123 ref.current.dispatchEvent(createPointerEvent('touchstart'));
124 expect(onPressStart).toHaveBeenCalledTimes(1);
125 + expect(onPressStart).toHaveBeenCalledWith(
126 + expect.objectContaining({pointerType: 'touch', type: 'pressstart'}),
127 + );
128 });
129
130 describe('delayPressStart', () => {
@@ -191,8 +209,13 @@ describe('Event responder: Press', () => {
209
210 it('is called after "pointerup" event', () => {
211 ref.current.dispatchEvent(createPointerEvent('pointerdown'));
194 - ref.current.dispatchEvent(createPointerEvent('pointerup'));
212 + ref.current.dispatchEvent(
213 + createPointerEvent('pointerup', {pointerType: 'pen'}),
214 + );
215 expect(onPressEnd).toHaveBeenCalledTimes(1);
216 + expect(onPressEnd).toHaveBeenCalledWith(
217 + expect.objectContaining({pointerType: 'pen', type: 'pressend'}),
218 + );
219 });
220
221 it('ignores browser emulated "mouseup" event', () => {
@@ -200,18 +223,27 @@ describe('Event responder: Press', () => {
223 ref.current.dispatchEvent(createPointerEvent('touchend'));
224 ref.current.dispatchEvent(createPointerEvent('mouseup'));
225 expect(onPressEnd).toHaveBeenCalledTimes(1);
226 + expect(onPressEnd).toHaveBeenCalledWith(
227 + expect.objectContaining({pointerType: 'touch', type: 'pressend'}),
228 + );
229 });
230
231 it('is called after "keyup" event for Enter', () => {
232 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'}));
233 ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'}));
234 expect(onPressEnd).toHaveBeenCalledTimes(1);
235 + expect(onPressEnd).toHaveBeenCalledWith(
236 + expect.objectContaining({pointerType: 'keyboard', type: 'pressend'}),
237 + );
238 });
239
240 it('is called after "keyup" event for Spacebar', () => {
241 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: ' '}));
242 ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: ' '}));
243 expect(onPressEnd).toHaveBeenCalledTimes(1);
244 + expect(onPressEnd).toHaveBeenCalledWith(
245 + expect.objectContaining({pointerType: 'keyboard', type: 'pressend'}),
246 + );
247 });
248
249 it('is not called after "keyup" event for other keys', () => {
@@ -225,11 +257,17 @@ describe('Event responder: Press', () => {
257 ref.current.dispatchEvent(createPointerEvent('mousedown'));
258 ref.current.dispatchEvent(createPointerEvent('mouseup'));
259 expect(onPressEnd).toHaveBeenCalledTimes(1);
260 + expect(onPressEnd).toHaveBeenCalledWith(
261 + expect.objectContaining({pointerType: 'mouse', type: 'pressend'}),
262 + );
263 });
264 it('is called after "touchend" event', () => {
265 ref.current.dispatchEvent(createPointerEvent('touchstart'));
266 ref.current.dispatchEvent(createPointerEvent('touchend'));
267 expect(onPressEnd).toHaveBeenCalledTimes(1);
268 + expect(onPressEnd).toHaveBeenCalledWith(
269 + expect.objectContaining({pointerType: 'touch', type: 'pressend'}),
270 + );
271 });
272
273 describe('delayPressEnd', () => {
@@ -342,6 +380,9 @@ describe('Event responder: Press', () => {
380 ref.current.dispatchEvent(createPointerEvent('pointerdown'));
381 jest.advanceTimersByTime(100);
382 ref.current.dispatchEvent(createPointerEvent('pointerup'));
383 + jest.advanceTimersByTime(10);
384 + expect(onPressChange).toHaveBeenCalledWith(true);
385 + expect(onPressChange).toHaveBeenCalledWith(false);
386 expect(onPressChange).toHaveBeenCalledTimes(2);
387 });
388
@@ -431,14 +472,22 @@ describe('Event responder: Press', () => {
472
473 it('is called after "pointerup" event', () => {
474 ref.current.dispatchEvent(createPointerEvent('pointerdown'));
434 - ref.current.dispatchEvent(createPointerEvent('pointerup'));
475 + ref.current.dispatchEvent(
476 + createPointerEvent('pointerup', {pointerType: 'pen'}),
477 + );
478 expect(onPress).toHaveBeenCalledTimes(1);
479 + expect(onPress).toHaveBeenCalledWith(
480 + expect.objectContaining({pointerType: 'pen', type: 'press'}),
481 + );
482 });
483
484 it('is called after valid "keyup" event', () => {
485 ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'}));
486 ref.current.dispatchEvent(createKeyboardEvent('keyup', {key: 'Enter'}));
487 expect(onPress).toHaveBeenCalledTimes(1);
488 + expect(onPress).toHaveBeenCalledWith(
489 + expect.objectContaining({pointerType: 'keyboard', type: 'press'}),
490 + );
491 });
492
493 it('is always called immediately after press is released', () => {
@@ -508,11 +557,16 @@ describe('Event responder: Press', () => {
557 });
558
559 it('is called if "pointerdown" lasts default delay', () => {
511 - ref.current.dispatchEvent(createPointerEvent('pointerdown'));
560 + ref.current.dispatchEvent(
561 + createPointerEvent('pointerdown', {pointerType: 'pen'}),
562 + );
563 jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY - 1);
564 expect(onLongPress).not.toBeCalled();
565 jest.advanceTimersByTime(1);
566 expect(onLongPress).toHaveBeenCalledTimes(1);
567 + expect(onLongPress).toHaveBeenCalledWith(
568 + expect.objectContaining({pointerType: 'pen', type: 'longpress'}),
569 + );
570 });
571
572 it('is not called if "pointerup" is dispatched before delay', () => {
@@ -529,6 +583,9 @@ describe('Event responder: Press', () => {
583 expect(onLongPress).not.toBeCalled();
584 jest.advanceTimersByTime(1);
585 expect(onLongPress).toHaveBeenCalledTimes(1);
586 + expect(onLongPress).toHaveBeenCalledWith(
587 + expect.objectContaining({pointerType: 'keyboard', type: 'longpress'}),
588 + );
589 });
590
591 it('is not called if valid "keyup" is dispatched before delay', () => {
@@ -691,6 +748,38 @@ describe('Event responder: Press', () => {
748 });
749 });
750
751 + describe('onPressMove', () => {
752 + it('is called after "pointermove"', () => {
753 + const onPressMove = jest.fn();
754 + const ref = React.createRef();
755 + const element = (
756 + <Press onPressMove={onPressMove}>
757 + <div ref={ref} />
758 + </Press>
759 + );
760 + ReactDOM.render(element, container);
761 +
762 + ref.current.getBoundingClientRect = () => ({
763 + top: 50,
764 + left: 50,
765 + bottom: 500,
766 + right: 500,
767 + });
768 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
769 + ref.current.dispatchEvent(
770 + createPointerEvent('pointermove', {
771 + pointerType: 'touch',
772 + pageX: 55,
773 + pageY: 55,
774 + }),
775 + );
776 + expect(onPressMove).toHaveBeenCalledTimes(1);
777 + expect(onPressMove).toHaveBeenCalledWith(
778 + expect.objectContaining({pointerType: 'touch', type: 'pressmove'}),
779 + );
780 + });
781 + });
782 +
783 describe('press with movement', () => {
784 const rectMock = {
785 width: 100,
@@ -1042,6 +1131,67 @@ describe('Event responder: Press', () => {
1131 });
1132 });
1133
1134 + describe('link components', () => {
1135 + it('prevents native behaviour by default', () => {
1136 + const onPress = jest.fn();
1137 + const preventDefault = jest.fn();
1138 + const ref = React.createRef();
1139 + const element = (
1140 + <Press onPress={onPress}>
1141 + <a href="#" ref={ref} />
1142 + </Press>
1143 + );
1144 + ReactDOM.render(element, container);
1145 +
1146 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
1147 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
1148 + ref.current.dispatchEvent(createPointerEvent('click', {preventDefault}));
1149 + expect(preventDefault).toBeCalled();
1150 + });
1151 +
1152 + it('uses native behaviour for interactions with modifier keys', () => {
1153 + const onPress = jest.fn();
1154 + const preventDefault = jest.fn();
1155 + const ref = React.createRef();
1156 + const element = (
1157 + <Press onPress={onPress}>
1158 + <a href="#" ref={ref} />
1159 + </Press>
1160 + );
1161 + ReactDOM.render(element, container);
1162 +
1163 + ['metaKey', 'ctrlKey', 'shiftKey'].forEach(modifierKey => {
1164 + ref.current.dispatchEvent(
1165 + createPointerEvent('pointerdown', {[modifierKey]: true}),
1166 + );
1167 + ref.current.dispatchEvent(
1168 + createPointerEvent('pointerup', {[modifierKey]: true}),
1169 + );
1170 + ref.current.dispatchEvent(
1171 + createPointerEvent('click', {[modifierKey]: true, preventDefault}),
1172 + );
1173 + expect(preventDefault).not.toBeCalled();
1174 + });
1175 + });
1176 +
1177 + it('uses native behaviour if preventDefault is false', () => {
1178 + const onPress = jest.fn();
1179 + const preventDefault = jest.fn();
1180 + const ref = React.createRef();
1181 + const element = (
1182 + <Press onPress={onPress} preventDefault={false}>
1183 + <a href="#" ref={ref} />
1184 + </Press>
1185 + );
1186 + ReactDOM.render(element, container);
1187 +
1188 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
1189 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
1190 + ref.current.dispatchEvent(createPointerEvent('click', {preventDefault}));
1191 + expect(preventDefault).not.toBeCalled();
1192 + });
1193 + });
1194 +
1195 it('expect displayName to show up for event component', () => {
1196 expect(Press.displayName).toBe('Press');
1197 });