UNPKG

25.8 kBPlain TextView Raw
1/*
2 * Copyright 2020 Adobe. All rights reserved.
3 * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4 * you may not use this file except in compliance with the License. You may obtain a copy
5 * of the License at http://www.apache.org/licenses/LICENSE-2.0
6 *
7 * Unless required by applicable law or agreed to in writing, software distributed under
8 * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9 * OF ANY KIND, either express or implied. See the License for the specific language
10 * governing permissions and limitations under the License.
11 */
12
13// Portions of the code in this file are based on code from react.
14// Original licensing for the following can be found in the
15// NOTICE file in the root directory of this source tree.
16// See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
17
18import {disableTextSelection, restoreTextSelection} from './textSelection';
19import {focusWithoutScrolling, mergeProps, useGlobalListeners, useSyncRef} from '@react-aria/utils';
20import {HTMLAttributes, RefObject, useContext, useEffect, useMemo, useRef, useState} from 'react';
21import {isVirtualClick} from './utils';
22import {PointerType, PressEvents} from '@react-types/shared';
23import {PressResponderContext} from './context';
24
25export interface PressProps extends PressEvents {
26 /** Whether the target is in a controlled press state (e.g. an overlay it triggers is open). */
27 isPressed?: boolean,
28 /** Whether the press events should be disabled. */
29 isDisabled?: boolean,
30 /** Whether the target should not receive focus on press. */
31 preventFocusOnPress?: boolean,
32 /**
33 * Whether press events should be canceled when the pointer leaves the target while pressed.
34 * By default, this is `false`, which means if the pointer returns back over the target while
35 * still pressed, onPressStart will be fired again. If set to `true`, the press is canceled
36 * when the pointer leaves the target and onPressStart will not be fired if the pointer returns.
37 */
38 shouldCancelOnPointerExit?: boolean,
39 /** Whether text selection should be enabled on the pressable element. */
40 allowTextSelectionOnPress?: boolean
41}
42
43export interface PressHookProps extends PressProps {
44 /** A ref to the target element. */
45 ref?: RefObject<HTMLElement>
46}
47
48interface PressState {
49 isPressed: boolean,
50 ignoreEmulatedMouseEvents: boolean,
51 ignoreClickAfterPress: boolean,
52 didFirePressStart: boolean,
53 activePointerId: any,
54 target: HTMLElement | null,
55 isOverTarget: boolean,
56 pointerType: PointerType,
57 userSelect?: string
58}
59
60interface EventBase {
61 currentTarget: EventTarget,
62 shiftKey: boolean,
63 ctrlKey: boolean,
64 metaKey: boolean,
65 altKey: boolean
66}
67
68export interface PressResult {
69 /** Whether the target is currently pressed. */
70 isPressed: boolean,
71 /** Props to spread on the target element. */
72 pressProps: HTMLAttributes<HTMLElement>
73}
74
75function usePressResponderContext(props: PressHookProps): PressHookProps {
76 // Consume context from <PressResponder> and merge with props.
77 let context = useContext(PressResponderContext);
78 if (context) {
79 let {register, ...contextProps} = context;
80 props = mergeProps(contextProps, props) as PressHookProps;
81 register();
82 }
83 useSyncRef(context, props.ref);
84
85 return props;
86}
87
88/**
89 * Handles press interactions across mouse, touch, keyboard, and screen readers.
90 * It normalizes behavior across browsers and platforms, and handles many nuances
91 * of dealing with pointer and keyboard events.
92 */
93export function usePress(props: PressHookProps): PressResult {
94 let {
95 onPress,
96 onPressChange,
97 onPressStart,
98 onPressEnd,
99 onPressUp,
100 isDisabled,
101 isPressed: isPressedProp,
102 preventFocusOnPress,
103 shouldCancelOnPointerExit,
104 allowTextSelectionOnPress,
105 // eslint-disable-next-line @typescript-eslint/no-unused-vars
106 ref: _, // Removing `ref` from `domProps` because TypeScript is dumb
107 ...domProps
108 } = usePressResponderContext(props);
109 let propsRef = useRef<PressHookProps>(null);
110 propsRef.current = {onPress, onPressChange, onPressStart, onPressEnd, onPressUp, isDisabled, shouldCancelOnPointerExit};
111
112 let [isPressed, setPressed] = useState(false);
113 let ref = useRef<PressState>({
114 isPressed: false,
115 ignoreEmulatedMouseEvents: false,
116 ignoreClickAfterPress: false,
117 didFirePressStart: false,
118 activePointerId: null,
119 target: null,
120 isOverTarget: false,
121 pointerType: null
122 });
123
124 let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
125
126 let pressProps = useMemo(() => {
127 let state = ref.current;
128 let triggerPressStart = (originalEvent: EventBase, pointerType: PointerType) => {
129 let {onPressStart, onPressChange, isDisabled} = propsRef.current;
130 if (isDisabled || state.didFirePressStart) {
131 return;
132 }
133
134 if (onPressStart) {
135 onPressStart({
136 type: 'pressstart',
137 pointerType,
138 target: originalEvent.currentTarget as HTMLElement,
139 shiftKey: originalEvent.shiftKey,
140 metaKey: originalEvent.metaKey,
141 ctrlKey: originalEvent.ctrlKey,
142 altKey: originalEvent.altKey
143 });
144 }
145
146 if (onPressChange) {
147 onPressChange(true);
148 }
149
150 state.didFirePressStart = true;
151 setPressed(true);
152 };
153
154 let triggerPressEnd = (originalEvent: EventBase, pointerType: PointerType, wasPressed = true) => {
155 let {onPressEnd, onPressChange, onPress, isDisabled} = propsRef.current;
156 if (!state.didFirePressStart) {
157 return;
158 }
159
160 state.ignoreClickAfterPress = true;
161 state.didFirePressStart = false;
162
163 if (onPressEnd) {
164 onPressEnd({
165 type: 'pressend',
166 pointerType,
167 target: originalEvent.currentTarget as HTMLElement,
168 shiftKey: originalEvent.shiftKey,
169 metaKey: originalEvent.metaKey,
170 ctrlKey: originalEvent.ctrlKey,
171 altKey: originalEvent.altKey
172 });
173 }
174
175 if (onPressChange) {
176 onPressChange(false);
177 }
178
179 setPressed(false);
180
181 if (onPress && wasPressed && !isDisabled) {
182 onPress({
183 type: 'press',
184 pointerType,
185 target: originalEvent.currentTarget as HTMLElement,
186 shiftKey: originalEvent.shiftKey,
187 metaKey: originalEvent.metaKey,
188 ctrlKey: originalEvent.ctrlKey,
189 altKey: originalEvent.altKey
190 });
191 }
192 };
193
194 let triggerPressUp = (originalEvent: EventBase, pointerType: PointerType) => {
195 let {onPressUp, isDisabled} = propsRef.current;
196 if (isDisabled) {
197 return;
198 }
199
200 if (onPressUp) {
201 onPressUp({
202 type: 'pressup',
203 pointerType,
204 target: originalEvent.currentTarget as HTMLElement,
205 shiftKey: originalEvent.shiftKey,
206 metaKey: originalEvent.metaKey,
207 ctrlKey: originalEvent.ctrlKey,
208 altKey: originalEvent.altKey
209 });
210 }
211 };
212
213 let cancel = (e: EventBase) => {
214 if (state.isPressed) {
215 if (state.isOverTarget) {
216 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
217 }
218 state.isPressed = false;
219 state.isOverTarget = false;
220 state.activePointerId = null;
221 state.pointerType = null;
222 removeAllGlobalListeners();
223 if (!allowTextSelectionOnPress) {
224 restoreTextSelection(state.target);
225 }
226 }
227 };
228
229 let pressProps: HTMLAttributes<HTMLElement> = {
230 onKeyDown(e) {
231 if (isValidKeyboardEvent(e.nativeEvent) && e.currentTarget.contains(e.target as HTMLElement)) {
232 if (shouldPreventDefaultKeyboard(e.target as Element)) {
233 e.preventDefault();
234 }
235 e.stopPropagation();
236
237 // If the event is repeating, it may have started on a different element
238 // after which focus moved to the current element. Ignore these events and
239 // only handle the first key down event.
240 if (!state.isPressed && !e.repeat) {
241 state.target = e.currentTarget as HTMLElement;
242 state.isPressed = true;
243 triggerPressStart(e, 'keyboard');
244
245 // Focus may move before the key up event, so register the event on the document
246 // instead of the same element where the key down event occurred.
247 addGlobalListener(document, 'keyup', onKeyUp, false);
248 }
249 }
250 },
251 onKeyUp(e) {
252 if (isValidKeyboardEvent(e.nativeEvent) && !e.repeat && e.currentTarget.contains(e.target as HTMLElement)) {
253 triggerPressUp(createEvent(state.target, e), 'keyboard');
254 }
255 },
256 onClick(e) {
257 if (e && !e.currentTarget.contains(e.target as HTMLElement)) {
258 return;
259 }
260
261 if (e && e.button === 0) {
262 e.stopPropagation();
263 if (isDisabled) {
264 e.preventDefault();
265 }
266
267 // If triggered from a screen reader or by using element.click(),
268 // trigger as if it were a keyboard click.
269 if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
270 // Ensure the element receives focus (VoiceOver on iOS does not do this)
271 if (!isDisabled && !preventFocusOnPress) {
272 focusWithoutScrolling(e.currentTarget);
273 }
274
275 triggerPressStart(e, 'virtual');
276 triggerPressUp(e, 'virtual');
277 triggerPressEnd(e, 'virtual');
278 }
279
280 state.ignoreEmulatedMouseEvents = false;
281 state.ignoreClickAfterPress = false;
282 }
283 }
284 };
285
286 let onKeyUp = (e: KeyboardEvent) => {
287 if (state.isPressed && isValidKeyboardEvent(e)) {
288 if (shouldPreventDefaultKeyboard(e.target as Element)) {
289 e.preventDefault();
290 }
291 e.stopPropagation();
292
293 state.isPressed = false;
294 let target = e.target as HTMLElement;
295 triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
296 removeAllGlobalListeners();
297
298 // If the target is a link, trigger the click method to open the URL,
299 // but defer triggering pressEnd until onClick event handler.
300 if (state.target.contains(target) && isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link') {
301 state.target.click();
302 }
303 }
304 };
305
306 if (typeof PointerEvent !== 'undefined') {
307 pressProps.onPointerDown = (e) => {
308 // Only handle left clicks, and ignore events that bubbled through portals.
309 if (e.button !== 0 || !e.currentTarget.contains(e.target as HTMLElement)) {
310 return;
311 }
312
313 // iOS safari fires pointer events from VoiceOver with incorrect coordinates/target.
314 // Ignore and let the onClick handler take care of it instead.
315 // https://bugs.webkit.org/show_bug.cgi?id=222627
316 // https://bugs.webkit.org/show_bug.cgi?id=223202
317 if (isVirtualPointerEvent(e.nativeEvent)) {
318 state.pointerType = 'virtual';
319 return;
320 }
321
322 // Due to browser inconsistencies, especially on mobile browsers, we prevent
323 // default on pointer down and handle focusing the pressable element ourselves.
324 if (shouldPreventDefault(e.currentTarget as HTMLElement)) {
325 e.preventDefault();
326 }
327
328 state.pointerType = e.pointerType;
329
330 e.stopPropagation();
331 if (!state.isPressed) {
332 state.isPressed = true;
333 state.isOverTarget = true;
334 state.activePointerId = e.pointerId;
335 state.target = e.currentTarget;
336
337 if (!isDisabled && !preventFocusOnPress) {
338 focusWithoutScrolling(e.currentTarget);
339 }
340
341 if (!allowTextSelectionOnPress) {
342 disableTextSelection(state.target);
343 }
344
345 triggerPressStart(e, state.pointerType);
346
347 addGlobalListener(document, 'pointermove', onPointerMove, false);
348 addGlobalListener(document, 'pointerup', onPointerUp, false);
349 addGlobalListener(document, 'pointercancel', onPointerCancel, false);
350 }
351 };
352
353 pressProps.onMouseDown = (e) => {
354 if (!e.currentTarget.contains(e.target as HTMLElement)) {
355 return;
356 }
357
358 if (e.button === 0) {
359 // Chrome and Firefox on touch Windows devices require mouse down events
360 // to be canceled in addition to pointer events, or an extra asynchronous
361 // focus event will be fired.
362 if (shouldPreventDefault(e.currentTarget as HTMLElement)) {
363 e.preventDefault();
364 }
365
366 e.stopPropagation();
367 }
368 };
369
370 pressProps.onPointerUp = (e) => {
371 // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
372 if (!e.currentTarget.contains(e.target as HTMLElement) || state.pointerType === 'virtual') {
373 return;
374 }
375
376 // Only handle left clicks
377 // Safari on iOS sometimes fires pointerup events, even
378 // when the touch isn't over the target, so double check.
379 if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
380 triggerPressUp(e, state.pointerType || e.pointerType);
381 }
382 };
383
384 // Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly.
385 // Use pointer move events instead to implement our own hit testing.
386 // See https://bugs.webkit.org/show_bug.cgi?id=199803
387 let onPointerMove = (e: PointerEvent) => {
388 if (e.pointerId !== state.activePointerId) {
389 return;
390 }
391
392 if (isOverTarget(e, state.target)) {
393 if (!state.isOverTarget) {
394 state.isOverTarget = true;
395 triggerPressStart(createEvent(state.target, e), state.pointerType);
396 }
397 } else if (state.isOverTarget) {
398 state.isOverTarget = false;
399 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
400 if (propsRef.current.shouldCancelOnPointerExit) {
401 cancel(e);
402 }
403 }
404 };
405
406 let onPointerUp = (e: PointerEvent) => {
407 if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0) {
408 if (isOverTarget(e, state.target)) {
409 triggerPressEnd(createEvent(state.target, e), state.pointerType);
410 } else if (state.isOverTarget) {
411 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
412 }
413
414 state.isPressed = false;
415 state.isOverTarget = false;
416 state.activePointerId = null;
417 state.pointerType = null;
418 removeAllGlobalListeners();
419 if (!allowTextSelectionOnPress) {
420 restoreTextSelection(state.target);
421 }
422 }
423 };
424
425 let onPointerCancel = (e: PointerEvent) => {
426 cancel(e);
427 };
428
429 pressProps.onDragStart = (e) => {
430 if (!e.currentTarget.contains(e.target as HTMLElement)) {
431 return;
432 }
433
434 // Safari does not call onPointerCancel when a drag starts, whereas Chrome and Firefox do.
435 cancel(e);
436 };
437 } else {
438 pressProps.onMouseDown = (e) => {
439 // Only handle left clicks
440 if (e.button !== 0 || !e.currentTarget.contains(e.target as HTMLElement)) {
441 return;
442 }
443
444 // Due to browser inconsistencies, especially on mobile browsers, we prevent
445 // default on mouse down and handle focusing the pressable element ourselves.
446 if (shouldPreventDefault(e.currentTarget as HTMLElement)) {
447 e.preventDefault();
448 }
449
450 e.stopPropagation();
451 if (state.ignoreEmulatedMouseEvents) {
452 return;
453 }
454
455 state.isPressed = true;
456 state.isOverTarget = true;
457 state.target = e.currentTarget;
458 state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse';
459
460 if (!isDisabled && !preventFocusOnPress) {
461 focusWithoutScrolling(e.currentTarget);
462 }
463
464 triggerPressStart(e, state.pointerType);
465
466 addGlobalListener(document, 'mouseup', onMouseUp, false);
467 };
468
469 pressProps.onMouseEnter = (e) => {
470 if (!e.currentTarget.contains(e.target as HTMLElement)) {
471 return;
472 }
473
474 e.stopPropagation();
475 if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
476 state.isOverTarget = true;
477 triggerPressStart(e, state.pointerType);
478 }
479 };
480
481 pressProps.onMouseLeave = (e) => {
482 if (!e.currentTarget.contains(e.target as HTMLElement)) {
483 return;
484 }
485
486 e.stopPropagation();
487 if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
488 state.isOverTarget = false;
489 triggerPressEnd(e, state.pointerType, false);
490 if (propsRef.current.shouldCancelOnPointerExit) {
491 cancel(e);
492 }
493 }
494 };
495
496 pressProps.onMouseUp = (e) => {
497 if (!e.currentTarget.contains(e.target as HTMLElement)) {
498 return;
499 }
500
501 if (!state.ignoreEmulatedMouseEvents && e.button === 0) {
502 triggerPressUp(e, state.pointerType);
503 }
504 };
505
506 let onMouseUp = (e: MouseEvent) => {
507 // Only handle left clicks
508 if (e.button !== 0) {
509 return;
510 }
511
512 state.isPressed = false;
513 removeAllGlobalListeners();
514
515 if (state.ignoreEmulatedMouseEvents) {
516 state.ignoreEmulatedMouseEvents = false;
517 return;
518 }
519
520 if (isOverTarget(e, state.target)) {
521 triggerPressEnd(createEvent(state.target, e), state.pointerType);
522 } else if (state.isOverTarget) {
523 triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
524 }
525
526 state.isOverTarget = false;
527 };
528
529 pressProps.onTouchStart = (e) => {
530 if (!e.currentTarget.contains(e.target as HTMLElement)) {
531 return;
532 }
533
534 e.stopPropagation();
535 let touch = getTouchFromEvent(e.nativeEvent);
536 if (!touch) {
537 return;
538 }
539 state.activePointerId = touch.identifier;
540 state.ignoreEmulatedMouseEvents = true;
541 state.isOverTarget = true;
542 state.isPressed = true;
543 state.target = e.currentTarget;
544 state.pointerType = 'touch';
545
546 // Due to browser inconsistencies, especially on mobile browsers, we prevent default
547 // on the emulated mouse event and handle focusing the pressable element ourselves.
548 if (!isDisabled && !preventFocusOnPress) {
549 focusWithoutScrolling(e.currentTarget);
550 }
551
552 if (!allowTextSelectionOnPress) {
553 disableTextSelection(state.target);
554 }
555
556 triggerPressStart(e, state.pointerType);
557
558 addGlobalListener(window, 'scroll', onScroll, true);
559 };
560
561 pressProps.onTouchMove = (e) => {
562 if (!e.currentTarget.contains(e.target as HTMLElement)) {
563 return;
564 }
565
566 e.stopPropagation();
567 if (!state.isPressed) {
568 return;
569 }
570
571 let touch = getTouchById(e.nativeEvent, state.activePointerId);
572 if (touch && isOverTarget(touch, e.currentTarget)) {
573 if (!state.isOverTarget) {
574 state.isOverTarget = true;
575 triggerPressStart(e, state.pointerType);
576 }
577 } else if (state.isOverTarget) {
578 state.isOverTarget = false;
579 triggerPressEnd(e, state.pointerType, false);
580 if (propsRef.current.shouldCancelOnPointerExit) {
581 cancel(e);
582 }
583 }
584 };
585
586 pressProps.onTouchEnd = (e) => {
587 if (!e.currentTarget.contains(e.target as HTMLElement)) {
588 return;
589 }
590
591 e.stopPropagation();
592 if (!state.isPressed) {
593 return;
594 }
595
596 let touch = getTouchById(e.nativeEvent, state.activePointerId);
597 if (touch && isOverTarget(touch, e.currentTarget)) {
598 triggerPressUp(e, state.pointerType);
599 triggerPressEnd(e, state.pointerType);
600 } else if (state.isOverTarget) {
601 triggerPressEnd(e, state.pointerType, false);
602 }
603
604 state.isPressed = false;
605 state.activePointerId = null;
606 state.isOverTarget = false;
607 state.ignoreEmulatedMouseEvents = true;
608 if (!allowTextSelectionOnPress) {
609 restoreTextSelection(state.target);
610 }
611 removeAllGlobalListeners();
612 };
613
614 pressProps.onTouchCancel = (e) => {
615 if (!e.currentTarget.contains(e.target as HTMLElement)) {
616 return;
617 }
618
619 e.stopPropagation();
620 if (state.isPressed) {
621 cancel(e);
622 }
623 };
624
625 let onScroll = (e: Event) => {
626 if (state.isPressed && (e.target as HTMLElement).contains(state.target)) {
627 cancel({
628 currentTarget: state.target,
629 shiftKey: false,
630 ctrlKey: false,
631 metaKey: false,
632 altKey: false
633 });
634 }
635 };
636
637 pressProps.onDragStart = (e) => {
638 if (!e.currentTarget.contains(e.target as HTMLElement)) {
639 return;
640 }
641
642 cancel(e);
643 };
644 }
645
646 return pressProps;
647 }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress]);
648
649 // Remove user-select: none in case component unmounts immediately after pressStart
650 // eslint-disable-next-line arrow-body-style
651 useEffect(() => {
652 return () => {
653 if (!allowTextSelectionOnPress) {
654 restoreTextSelection(ref.current.target);
655 }
656 };
657 }, [allowTextSelectionOnPress]);
658
659 return {
660 isPressed: isPressedProp || isPressed,
661 pressProps: mergeProps(domProps, pressProps)
662 };
663}
664
665function isHTMLAnchorLink(target: HTMLElement): boolean {
666 return target.tagName === 'A' && target.hasAttribute('href');
667}
668
669function isValidKeyboardEvent(event: KeyboardEvent): boolean {
670 const {key, code, target} = event;
671 const element = target as HTMLElement;
672 const {tagName, isContentEditable} = element;
673 const role = element.getAttribute('role');
674 // Accessibility for keyboards. Space and Enter only.
675 // "Spacebar" is for IE 11
676 return (
677 (key === 'Enter' || key === ' ' || key === 'Spacebar' || code === 'Space') &&
678 (tagName !== 'INPUT' &&
679 tagName !== 'TEXTAREA' &&
680 isContentEditable !== true) &&
681 // A link with a valid href should be handled natively,
682 // unless it also has role='button' and was triggered using Space.
683 (!isHTMLAnchorLink(element) || (role === 'button' && key !== 'Enter')) &&
684 // An element with role='link' should only trigger with Enter key
685 !(role === 'link' && key !== 'Enter')
686 );
687}
688
689function getTouchFromEvent(event: TouchEvent): Touch | null {
690 const {targetTouches} = event;
691 if (targetTouches.length > 0) {
692 return targetTouches[0];
693 }
694 return null;
695}
696
697function getTouchById(
698 event: TouchEvent,
699 pointerId: null | number
700): null | Touch {
701 const changedTouches = event.changedTouches;
702 for (let i = 0; i < changedTouches.length; i++) {
703 const touch = changedTouches[i];
704 if (touch.identifier === pointerId) {
705 return touch;
706 }
707 }
708 return null;
709}
710
711function createEvent(target: HTMLElement, e: EventBase): EventBase {
712 return {
713 currentTarget: target,
714 shiftKey: e.shiftKey,
715 ctrlKey: e.ctrlKey,
716 metaKey: e.metaKey,
717 altKey: e.altKey
718 };
719}
720
721interface Rect {
722 top: number,
723 right: number,
724 bottom: number,
725 left: number
726}
727
728interface EventPoint {
729 clientX: number,
730 clientY: number,
731 width?: number,
732 height?: number,
733 radiusX?: number,
734 radiusY?: number
735}
736
737function getPointClientRect(point: EventPoint): Rect {
738 let offsetX = (point.width / 2) || point.radiusX || 0;
739 let offsetY = (point.height / 2) || point.radiusY || 0;
740
741 return {
742 top: point.clientY - offsetY,
743 right: point.clientX + offsetX,
744 bottom: point.clientY + offsetY,
745 left: point.clientX - offsetX
746 };
747}
748
749function areRectanglesOverlapping(a: Rect, b: Rect) {
750 // check if they cannot overlap on x axis
751 if (a.left > b.right || b.left > a.right) {
752 return false;
753 }
754 // check if they cannot overlap on y axis
755 if (a.top > b.bottom || b.top > a.bottom) {
756 return false;
757 }
758 return true;
759}
760
761function isOverTarget(point: EventPoint, target: HTMLElement) {
762 let rect = target.getBoundingClientRect();
763 let pointRect = getPointClientRect(point);
764 return areRectanglesOverlapping(rect, pointRect);
765}
766
767function shouldPreventDefault(target: HTMLElement) {
768 // We cannot prevent default if the target is a draggable element.
769 return !target.draggable;
770}
771
772function shouldPreventDefaultKeyboard(target: Element) {
773 return !((target.tagName === 'INPUT' || target.tagName === 'BUTTON') && (target as HTMLButtonElement | HTMLInputElement).type === 'submit');
774}
775
776function isVirtualPointerEvent(event: PointerEvent) {
777 // If the pointer size is zero, then we assume it's from a screen reader.
778 // Android TalkBack double tap will sometimes return a event with width and height of 1
779 // and pointerType === 'mouse' so we need to check for a specific combination of event attributes.
780 // Cannot use "event.pressure === 0" as the sole check due to Safari pointer events always returning pressure === 0
781 // instead of .5, see https://bugs.webkit.org/show_bug.cgi?id=206216
782 return (
783 (event.width === 0 && event.height === 0) ||
784 (event.width === 1 &&
785 event.height === 1 &&
786 event.pressure === 0 &&
787 event.detail === 0
788 )
789 );
790}