@react-aria/interactions 3.4.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
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
+ import {HTMLAttributes, useRef} from 'react';
14
+ import {LongPressEvent} from '@react-types/shared';
15
+ import {mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils';
16
+ import {usePress} from './usePress';
17
+
18
+ interface LongPressProps {
19
+ /** Whether long press events should be disabled. */
20
+ isDisabled?: boolean,
21
+ /** Handler that is called when a long press interaction starts. */
22
+ onLongPressStart?: (e: LongPressEvent) => void,
23
+ /**
24
+ * Handler that is called when a long press interaction ends, either
25
+ * over the target or when the pointer leaves the target.
26
+ */
27
+ onLongPressEnd?: (e: LongPressEvent) => void,
28
+ /**
29
+ * Handler that is called when the threshold time is met while
30
+ * the press is over the target.
31
+ */
32
+ onLongPress?: (e: LongPressEvent) => void,
33
+ /**
34
+ * The amount of time in milliseconds to wait before triggering a long press.
35
+ * @default 500ms
36
+ */
37
+ threshold?: number,
38
+ /**
39
+ * A description for assistive techology users indicating that a long press
40
+ * action is available, e.g. "Long press to open menu".
41
+ */
42
+ accessibilityDescription?: string
43
+ }
44
+
45
+ interface LongPressResult {
46
+ /** Props to spread on the target element. */
47
+ longPressProps: HTMLAttributes<HTMLElement>
48
+ }
49
+
50
+ const DEFAULT_THRESHOLD = 500;
51
+
52
+ /**
53
+ * Handles long press interactions across mouse and touch devices. Supports a customizable time threshold,
54
+ * accessibility description, and normalizes behavior across browsers and devices.
55
+ */
56
+ export function useLongPress(props: LongPressProps): LongPressResult {
57
+ let {
58
+ isDisabled,
59
+ onLongPressStart,
60
+ onLongPressEnd,
61
+ onLongPress,
62
+ threshold = DEFAULT_THRESHOLD,
63
+ accessibilityDescription
64
+ } = props;
65
+
66
+ const timeRef = useRef(null);
67
+ let {addGlobalListener, removeGlobalListener} = useGlobalListeners();
68
+
69
+ let {pressProps} = usePress({
70
+ isDisabled,
71
+ onPressStart(e) {
72
+ if (e.pointerType === 'mouse' || e.pointerType === 'touch') {
73
+ if (onLongPressStart) {
74
+ onLongPressStart({
75
+ ...e,
76
+ type: 'longpressstart'
77
+ });
78
+ }
79
+
80
+ timeRef.current = setTimeout(() => {
81
+ // Prevent other usePress handlers from also handling this event.
82
+ e.target.dispatchEvent(new PointerEvent('pointercancel', {bubbles: true}));
83
+ if (onLongPress) {
84
+ onLongPress({
85
+ ...e,
86
+ type: 'longpress'
87
+ });
88
+ }
89
+ timeRef.current = null;
90
+ }, threshold);
91
+
92
+ // Prevent context menu, which may be opened on long press on touch devices
93
+ if (e.pointerType === 'touch') {
94
+ let onContextMenu = e => {
95
+ e.preventDefault();
96
+ };
97
+
98
+ addGlobalListener(e.target, 'contextmenu', onContextMenu, {once: true});
99
+ addGlobalListener(window, 'pointerup', () => {
100
+ // If no contextmenu event is fired quickly after pointerup, remove the handler
101
+ // so future context menu events outside a long press are not prevented.
102
+ setTimeout(() => {
103
+ removeGlobalListener(e.target, 'contextmenu', onContextMenu);
104
+ }, 30);
105
+ }, {once: true});
106
+ }
107
+ }
108
+ },
109
+ onPressEnd(e) {
110
+ if (timeRef.current) {
111
+ clearTimeout(timeRef.current);
112
+ }
113
+
114
+ if (onLongPressEnd && (e.pointerType === 'mouse' || e.pointerType === 'touch')) {
115
+ onLongPressEnd({
116
+ ...e,
117
+ type: 'longpressend'
118
+ });
119
+ }
120
+ }
121
+ });
122
+
123
+ let descriptionProps = useDescription(onLongPress && !isDisabled ? accessibilityDescription : null);
124
+
125
+ return {
126
+ longPressProps: mergeProps(pressProps, descriptionProps)
127
+ };
128
+ }
package/src/usePress.ts CHANGED
@@ -28,7 +28,16 @@ export interface PressProps extends PressEvents {
28
28
  /** Whether the press events should be disabled. */
29
29
  isDisabled?: boolean,
30
30
  /** Whether the target should not receive focus on press. */
31
- preventFocusOnPress?: boolean
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
32
41
  }
33
42
 
34
43
  export interface PressHookProps extends PressProps {
@@ -52,7 +61,8 @@ interface EventBase {
52
61
  currentTarget: EventTarget,
53
62
  shiftKey: boolean,
54
63
  ctrlKey: boolean,
55
- metaKey: boolean
64
+ metaKey: boolean,
65
+ altKey: boolean
56
66
  }
57
67
 
58
68
  export interface PressResult {
@@ -90,12 +100,14 @@ export function usePress(props: PressHookProps): PressResult {
90
100
  isDisabled,
91
101
  isPressed: isPressedProp,
92
102
  preventFocusOnPress,
103
+ shouldCancelOnPointerExit,
104
+ allowTextSelectionOnPress,
93
105
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
94
106
  ref: _, // Removing `ref` from `domProps` because TypeScript is dumb,
95
107
  ...domProps
96
108
  } = usePressResponderContext(props);
97
109
  let propsRef = useRef<PressHookProps>(null);
98
- propsRef.current = {onPress, onPressChange, onPressStart, onPressEnd, onPressUp, isDisabled};
110
+ propsRef.current = {onPress, onPressChange, onPressStart, onPressEnd, onPressUp, isDisabled, shouldCancelOnPointerExit};
99
111
 
100
112
  let [isPressed, setPressed] = useState(false);
101
113
  let ref = useRef<PressState>({
@@ -126,7 +138,8 @@ export function usePress(props: PressHookProps): PressResult {
126
138
  target: originalEvent.currentTarget as HTMLElement,
127
139
  shiftKey: originalEvent.shiftKey,
128
140
  metaKey: originalEvent.metaKey,
129
- ctrlKey: originalEvent.ctrlKey
141
+ ctrlKey: originalEvent.ctrlKey,
142
+ altKey: originalEvent.altKey
130
143
  });
131
144
  }
132
145
 
@@ -154,7 +167,8 @@ export function usePress(props: PressHookProps): PressResult {
154
167
  target: originalEvent.currentTarget as HTMLElement,
155
168
  shiftKey: originalEvent.shiftKey,
156
169
  metaKey: originalEvent.metaKey,
157
- ctrlKey: originalEvent.ctrlKey
170
+ ctrlKey: originalEvent.ctrlKey,
171
+ altKey: originalEvent.altKey
158
172
  });
159
173
  }
160
174
 
@@ -171,7 +185,8 @@ export function usePress(props: PressHookProps): PressResult {
171
185
  target: originalEvent.currentTarget as HTMLElement,
172
186
  shiftKey: originalEvent.shiftKey,
173
187
  metaKey: originalEvent.metaKey,
174
- ctrlKey: originalEvent.ctrlKey
188
+ ctrlKey: originalEvent.ctrlKey,
189
+ altKey: originalEvent.altKey
175
190
  });
176
191
  }
177
192
  };
@@ -189,7 +204,8 @@ export function usePress(props: PressHookProps): PressResult {
189
204
  target: originalEvent.currentTarget as HTMLElement,
190
205
  shiftKey: originalEvent.shiftKey,
191
206
  metaKey: originalEvent.metaKey,
192
- ctrlKey: originalEvent.ctrlKey
207
+ ctrlKey: originalEvent.ctrlKey,
208
+ altKey: originalEvent.altKey
193
209
  });
194
210
  }
195
211
  };
@@ -204,17 +220,18 @@ export function usePress(props: PressHookProps): PressResult {
204
220
  state.activePointerId = null;
205
221
  state.pointerType = null;
206
222
  removeAllGlobalListeners();
207
- restoreTextSelection();
223
+ if (!allowTextSelectionOnPress) {
224
+ restoreTextSelection(state.target);
225
+ }
208
226
  }
209
227
  };
210
228
 
211
229
  let pressProps: HTMLAttributes<HTMLElement> = {
212
230
  onKeyDown(e) {
213
- if (isValidKeyboardEvent(e.nativeEvent)) {
231
+ if (isValidKeyboardEvent(e.nativeEvent) && e.currentTarget.contains(e.target as HTMLElement)) {
214
232
  e.preventDefault();
215
233
  e.stopPropagation();
216
234
 
217
-
218
235
  // If the event is repeating, it may have started on a different element
219
236
  // after which focus moved to the current element. Ignore these events and
220
237
  // only handle the first key down event.
@@ -230,11 +247,15 @@ export function usePress(props: PressHookProps): PressResult {
230
247
  }
231
248
  },
232
249
  onKeyUp(e) {
233
- if (isValidKeyboardEvent(e.nativeEvent) && !e.repeat) {
250
+ if (isValidKeyboardEvent(e.nativeEvent) && !e.repeat && e.currentTarget.contains(e.target as HTMLElement)) {
234
251
  triggerPressUp(createEvent(state.target, e), 'keyboard');
235
252
  }
236
253
  },
237
254
  onClick(e) {
255
+ if (e && !e.currentTarget.contains(e.target as HTMLElement)) {
256
+ return;
257
+ }
258
+
238
259
  if (e && e.button === 0) {
239
260
  e.stopPropagation();
240
261
  if (isDisabled) {
@@ -243,7 +264,7 @@ export function usePress(props: PressHookProps): PressResult {
243
264
 
244
265
  // If triggered from a screen reader or by using element.click(),
245
266
  // trigger as if it were a keyboard click.
246
- if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && isVirtualClick(e.nativeEvent)) {
267
+ if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
247
268
  // Ensure the element receives focus (VoiceOver on iOS does not do this)
248
269
  if (!isDisabled && !preventFocusOnPress) {
249
270
  focusWithoutScrolling(e.currentTarget);
@@ -266,12 +287,13 @@ export function usePress(props: PressHookProps): PressResult {
266
287
  e.stopPropagation();
267
288
 
268
289
  state.isPressed = false;
269
- triggerPressEnd(createEvent(state.target, e), 'keyboard', e.target === state.target);
290
+ let target = e.target as HTMLElement;
291
+ triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
270
292
  removeAllGlobalListeners();
271
293
 
272
294
  // If the target is a link, trigger the click method to open the URL,
273
295
  // but defer triggering pressEnd until onClick event handler.
274
- if (e.target === state.target && isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link') {
296
+ if (state.target.contains(target) && isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link') {
275
297
  state.target.click();
276
298
  }
277
299
  }
@@ -279,8 +301,17 @@ export function usePress(props: PressHookProps): PressResult {
279
301
 
280
302
  if (typeof PointerEvent !== 'undefined') {
281
303
  pressProps.onPointerDown = (e) => {
282
- // Only handle left clicks
283
- if (e.button !== 0) {
304
+ // Only handle left clicks, and ignore events that bubbled through portals.
305
+ if (e.button !== 0 || !e.currentTarget.contains(e.target as HTMLElement)) {
306
+ return;
307
+ }
308
+
309
+ // iOS safari fires pointer events from VoiceOver with incorrect coordinates/target.
310
+ // Ignore and let the onClick handler take care of it instead.
311
+ // https://bugs.webkit.org/show_bug.cgi?id=222627
312
+ // https://bugs.webkit.org/show_bug.cgi?id=223202
313
+ if (isVirtualPointerEvent(e.nativeEvent)) {
314
+ state.pointerType = 'virtual';
284
315
  return;
285
316
  }
286
317
 
@@ -290,9 +321,7 @@ export function usePress(props: PressHookProps): PressResult {
290
321
  e.preventDefault();
291
322
  }
292
323
 
293
- // iOS safari fires pointer events from VoiceOver (but only when outside an iframe...)
294
- // https://bugs.webkit.org/show_bug.cgi?id=222627
295
- state.pointerType = isVirtualPointerEvent(e.nativeEvent) ? 'virtual' : e.pointerType;
324
+ state.pointerType = e.pointerType;
296
325
 
297
326
  e.stopPropagation();
298
327
  if (!state.isPressed) {
@@ -305,7 +334,10 @@ export function usePress(props: PressHookProps): PressResult {
305
334
  focusWithoutScrolling(e.currentTarget);
306
335
  }
307
336
 
308
- disableTextSelection();
337
+ if (!allowTextSelectionOnPress) {
338
+ disableTextSelection(state.target);
339
+ }
340
+
309
341
  triggerPressStart(e, state.pointerType);
310
342
 
311
343
  addGlobalListener(document, 'pointermove', onPointerMove, false);
@@ -315,6 +347,10 @@ export function usePress(props: PressHookProps): PressResult {
315
347
  };
316
348
 
317
349
  pressProps.onMouseDown = (e) => {
350
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
351
+ return;
352
+ }
353
+
318
354
  if (e.button === 0) {
319
355
  // Chrome and Firefox on touch Windows devices require mouse down events
320
356
  // to be canceled in addition to pointer events, or an extra asynchronous
@@ -328,11 +364,16 @@ export function usePress(props: PressHookProps): PressResult {
328
364
  };
329
365
 
330
366
  pressProps.onPointerUp = (e) => {
367
+ // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
368
+ if (!e.currentTarget.contains(e.target as HTMLElement) || state.pointerType === 'virtual') {
369
+ return;
370
+ }
371
+
331
372
  // Only handle left clicks
332
373
  // Safari on iOS sometimes fires pointerup events, even
333
374
  // when the touch isn't over the target, so double check.
334
375
  if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
335
- triggerPressUp(e, state.pointerType);
376
+ triggerPressUp(e, state.pointerType || e.pointerType);
336
377
  }
337
378
  };
338
379
 
@@ -352,6 +393,9 @@ export function usePress(props: PressHookProps): PressResult {
352
393
  } else if (state.isOverTarget) {
353
394
  state.isOverTarget = false;
354
395
  triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
396
+ if (propsRef.current.shouldCancelOnPointerExit) {
397
+ cancel(e);
398
+ }
355
399
  }
356
400
  };
357
401
 
@@ -368,7 +412,9 @@ export function usePress(props: PressHookProps): PressResult {
368
412
  state.activePointerId = null;
369
413
  state.pointerType = null;
370
414
  removeAllGlobalListeners();
371
- restoreTextSelection();
415
+ if (!allowTextSelectionOnPress) {
416
+ restoreTextSelection(state.target);
417
+ }
372
418
  }
373
419
  };
374
420
 
@@ -377,13 +423,17 @@ export function usePress(props: PressHookProps): PressResult {
377
423
  };
378
424
 
379
425
  pressProps.onDragStart = (e) => {
426
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
427
+ return;
428
+ }
429
+
380
430
  // Safari does not call onPointerCancel when a drag starts, whereas Chrome and Firefox do.
381
431
  cancel(e);
382
432
  };
383
433
  } else {
384
434
  pressProps.onMouseDown = (e) => {
385
435
  // Only handle left clicks
386
- if (e.button !== 0) {
436
+ if (e.button !== 0 || !e.currentTarget.contains(e.target as HTMLElement)) {
387
437
  return;
388
438
  }
389
439
 
@@ -413,6 +463,10 @@ export function usePress(props: PressHookProps): PressResult {
413
463
  };
414
464
 
415
465
  pressProps.onMouseEnter = (e) => {
466
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
467
+ return;
468
+ }
469
+
416
470
  e.stopPropagation();
417
471
  if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
418
472
  state.isOverTarget = true;
@@ -421,14 +475,25 @@ export function usePress(props: PressHookProps): PressResult {
421
475
  };
422
476
 
423
477
  pressProps.onMouseLeave = (e) => {
478
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
479
+ return;
480
+ }
481
+
424
482
  e.stopPropagation();
425
483
  if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
426
484
  state.isOverTarget = false;
427
485
  triggerPressEnd(e, state.pointerType, false);
486
+ if (propsRef.current.shouldCancelOnPointerExit) {
487
+ cancel(e);
488
+ }
428
489
  }
429
490
  };
430
491
 
431
492
  pressProps.onMouseUp = (e) => {
493
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
494
+ return;
495
+ }
496
+
432
497
  if (!state.ignoreEmulatedMouseEvents && e.button === 0) {
433
498
  triggerPressUp(e, state.pointerType);
434
499
  }
@@ -458,6 +523,10 @@ export function usePress(props: PressHookProps): PressResult {
458
523
  };
459
524
 
460
525
  pressProps.onTouchStart = (e) => {
526
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
527
+ return;
528
+ }
529
+
461
530
  e.stopPropagation();
462
531
  let touch = getTouchFromEvent(e.nativeEvent);
463
532
  if (!touch) {
@@ -476,13 +545,20 @@ export function usePress(props: PressHookProps): PressResult {
476
545
  focusWithoutScrolling(e.currentTarget);
477
546
  }
478
547
 
479
- disableTextSelection();
548
+ if (!allowTextSelectionOnPress) {
549
+ disableTextSelection(state.target);
550
+ }
551
+
480
552
  triggerPressStart(e, state.pointerType);
481
553
 
482
554
  addGlobalListener(window, 'scroll', onScroll, true);
483
555
  };
484
556
 
485
557
  pressProps.onTouchMove = (e) => {
558
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
559
+ return;
560
+ }
561
+
486
562
  e.stopPropagation();
487
563
  if (!state.isPressed) {
488
564
  return;
@@ -497,10 +573,17 @@ export function usePress(props: PressHookProps): PressResult {
497
573
  } else if (state.isOverTarget) {
498
574
  state.isOverTarget = false;
499
575
  triggerPressEnd(e, state.pointerType, false);
576
+ if (propsRef.current.shouldCancelOnPointerExit) {
577
+ cancel(e);
578
+ }
500
579
  }
501
580
  };
502
581
 
503
582
  pressProps.onTouchEnd = (e) => {
583
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
584
+ return;
585
+ }
586
+
504
587
  e.stopPropagation();
505
588
  if (!state.isPressed) {
506
589
  return;
@@ -518,11 +601,17 @@ export function usePress(props: PressHookProps): PressResult {
518
601
  state.activePointerId = null;
519
602
  state.isOverTarget = false;
520
603
  state.ignoreEmulatedMouseEvents = true;
521
- restoreTextSelection();
604
+ if (!allowTextSelectionOnPress) {
605
+ restoreTextSelection(state.target);
606
+ }
522
607
  removeAllGlobalListeners();
523
608
  };
524
609
 
525
610
  pressProps.onTouchCancel = (e) => {
611
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
612
+ return;
613
+ }
614
+
526
615
  e.stopPropagation();
527
616
  if (state.isPressed) {
528
617
  cancel(e);
@@ -535,24 +624,33 @@ export function usePress(props: PressHookProps): PressResult {
535
624
  currentTarget: state.target,
536
625
  shiftKey: false,
537
626
  ctrlKey: false,
538
- metaKey: false
627
+ metaKey: false,
628
+ altKey: false
539
629
  });
540
630
  }
541
631
  };
542
632
 
543
633
  pressProps.onDragStart = (e) => {
634
+ if (!e.currentTarget.contains(e.target as HTMLElement)) {
635
+ return;
636
+ }
637
+
544
638
  cancel(e);
545
639
  };
546
640
  }
547
641
 
548
642
  return pressProps;
549
- }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners]);
643
+ }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress]);
550
644
 
551
645
  // Remove user-select: none in case component unmounts immediately after pressStart
552
646
  // eslint-disable-next-line arrow-body-style
553
647
  useEffect(() => {
554
- return () => restoreTextSelection();
555
- }, []);
648
+ return () => {
649
+ if (!allowTextSelectionOnPress) {
650
+ restoreTextSelection(ref.current.target);
651
+ }
652
+ };
653
+ }, [allowTextSelectionOnPress]);
556
654
 
557
655
  return {
558
656
  isPressed: isPressedProp || isPressed,
@@ -565,14 +663,14 @@ function isHTMLAnchorLink(target: HTMLElement): boolean {
565
663
  }
566
664
 
567
665
  function isValidKeyboardEvent(event: KeyboardEvent): boolean {
568
- const {key, target} = event;
666
+ const {key, code, target} = event;
569
667
  const element = target as HTMLElement;
570
668
  const {tagName, isContentEditable} = element;
571
669
  const role = element.getAttribute('role');
572
670
  // Accessibility for keyboards. Space and Enter only.
573
671
  // "Spacebar" is for IE 11
574
672
  return (
575
- (key === 'Enter' || key === ' ' || key === 'Spacebar') &&
673
+ (key === 'Enter' || key === ' ' || key === 'Spacebar' || code === 'Space') &&
576
674
  (tagName !== 'INPUT' &&
577
675
  tagName !== 'TEXTAREA' &&
578
676
  isContentEditable !== true) &&
@@ -611,21 +709,55 @@ function createEvent(target: HTMLElement, e: EventBase): EventBase {
611
709
  currentTarget: target,
612
710
  shiftKey: e.shiftKey,
613
711
  ctrlKey: e.ctrlKey,
614
- metaKey: e.metaKey
712
+ metaKey: e.metaKey,
713
+ altKey: e.altKey
615
714
  };
616
715
  }
617
716
 
717
+ interface Rect {
718
+ top: number,
719
+ right: number,
720
+ bottom: number,
721
+ left: number
722
+ }
723
+
618
724
  interface EventPoint {
619
725
  clientX: number,
620
- clientY: number
726
+ clientY: number,
727
+ width?: number,
728
+ height?: number,
729
+ radiusX?: number,
730
+ radiusY?: number
731
+ }
732
+
733
+ function getPointClientRect(point: EventPoint): Rect {
734
+ let offsetX = (point.width / 2) || point.radiusX || 0;
735
+ let offsetY = (point.height / 2) || point.radiusY || 0;
736
+
737
+ return {
738
+ top: point.clientY - offsetY,
739
+ right: point.clientX + offsetX,
740
+ bottom: point.clientY + offsetY,
741
+ left: point.clientX - offsetX
742
+ };
743
+ }
744
+
745
+ function areRectanglesOverlapping(a: Rect, b: Rect) {
746
+ // check if they cannot overlap on x axis
747
+ if (a.left > b.right || b.left > a.right) {
748
+ return false;
749
+ }
750
+ // check if they cannot overlap on y axis
751
+ if (a.top > b.bottom || b.top > a.bottom) {
752
+ return false;
753
+ }
754
+ return true;
621
755
  }
622
756
 
623
757
  function isOverTarget(point: EventPoint, target: HTMLElement) {
624
758
  let rect = target.getBoundingClientRect();
625
- return (point.clientX || 0) >= (rect.left || 0) &&
626
- (point.clientX || 0) <= (rect.right || 0) &&
627
- (point.clientY || 0) >= (rect.top || 0) &&
628
- (point.clientY || 0) <= (rect.bottom || 0);
759
+ let pointRect = getPointClientRect(point);
760
+ return areRectanglesOverlapping(rect, pointRect);
629
761
  }
630
762
 
631
763
  function shouldPreventDefault(target: Element) {
@@ -635,5 +767,16 @@ function shouldPreventDefault(target: Element) {
635
767
 
636
768
  function isVirtualPointerEvent(event: PointerEvent) {
637
769
  // If the pointer size is zero, then we assume it's from a screen reader.
638
- return event.width === 0 && event.height === 0;
770
+ // Android TalkBack double tap will sometimes return a event with width and height of 1
771
+ // and pointerType === 'mouse' so we need to check for a specific combination of event attributes.
772
+ // Cannot use "event.pressure === 0" as the sole check due to Safari pointer events always returning pressure === 0
773
+ // instead of .5, see https://bugs.webkit.org/show_bug.cgi?id=206216
774
+ return (
775
+ (event.width === 0 && event.height === 0) ||
776
+ (event.width === 1 &&
777
+ event.height === 1 &&
778
+ event.pressure === 0 &&
779
+ event.detail === 0
780
+ )
781
+ );
639
782
  }
@@ -10,8 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {RefObject, useCallback, useEffect} from 'react';
13
+ import {RefObject, useCallback} from 'react';
14
14
  import {ScrollEvents} from '@react-types/shared';
15
+ import {useEvent} from '@react-aria/utils';
15
16
 
16
17
  export interface ScrollWheelProps extends ScrollEvents {
17
18
  /** Whether the scroll listener should be disabled. */
@@ -36,15 +37,5 @@ export function useScrollWheel(props: ScrollWheelProps, ref: RefObject<HTMLEleme
36
37
  }
37
38
  }, [onScroll]);
38
39
 
39
- useEffect(() => {
40
- let elem = ref.current;
41
- if (isDisabled) {
42
- return;
43
- }
44
- elem.addEventListener('wheel', onScrollHandler);
45
-
46
- return () => {
47
- elem.removeEventListener('wheel', onScrollHandler);
48
- };
49
- }, [onScrollHandler, ref, isDisabled]);
40
+ useEvent(ref, 'wheel', isDisabled ? null : onScrollHandler);
50
41
  }