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
});