@react-aria/interactions 3.5.1 → 3.8.1

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.
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
- ref: _, // Removing `ref` from `domProps` because TypeScript is dumb,
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,14 +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
231
  if (isValidKeyboardEvent(e.nativeEvent) && e.currentTarget.contains(e.target as HTMLElement)) {
214
- e.preventDefault();
232
+ if (shouldPreventDefaultKeyboard(e.target as Element)) {
233
+ e.preventDefault();
234
+ }
215
235
  e.stopPropagation();
216
236
 
217
237
  // If the event is repeating, it may have started on a different element
@@ -246,7 +266,7 @@ export function usePress(props: PressHookProps): PressResult {
246
266
 
247
267
  // If triggered from a screen reader or by using element.click(),
248
268
  // trigger as if it were a keyboard click.
249
- if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && isVirtualClick(e.nativeEvent)) {
269
+ if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
250
270
  // Ensure the element receives focus (VoiceOver on iOS does not do this)
251
271
  if (!isDisabled && !preventFocusOnPress) {
252
272
  focusWithoutScrolling(e.currentTarget);
@@ -265,16 +285,19 @@ export function usePress(props: PressHookProps): PressResult {
265
285
 
266
286
  let onKeyUp = (e: KeyboardEvent) => {
267
287
  if (state.isPressed && isValidKeyboardEvent(e)) {
268
- e.preventDefault();
288
+ if (shouldPreventDefaultKeyboard(e.target as Element)) {
289
+ e.preventDefault();
290
+ }
269
291
  e.stopPropagation();
270
292
 
271
293
  state.isPressed = false;
272
- triggerPressEnd(createEvent(state.target, e), 'keyboard', e.target === state.target);
294
+ let target = e.target as HTMLElement;
295
+ triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
273
296
  removeAllGlobalListeners();
274
297
 
275
298
  // If the target is a link, trigger the click method to open the URL,
276
299
  // but defer triggering pressEnd until onClick event handler.
277
- if (e.target === state.target && isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link') {
300
+ if (state.target.contains(target) && isHTMLAnchorLink(state.target) || state.target.getAttribute('role') === 'link') {
278
301
  state.target.click();
279
302
  }
280
303
  }
@@ -287,15 +310,22 @@ export function usePress(props: PressHookProps): PressResult {
287
310
  return;
288
311
  }
289
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
+
290
322
  // Due to browser inconsistencies, especially on mobile browsers, we prevent
291
323
  // default on pointer down and handle focusing the pressable element ourselves.
292
324
  if (shouldPreventDefault(e.target as Element)) {
293
325
  e.preventDefault();
294
326
  }
295
327
 
296
- // iOS safari fires pointer events from VoiceOver (but only when outside an iframe...)
297
- // https://bugs.webkit.org/show_bug.cgi?id=222627
298
- state.pointerType = isVirtualPointerEvent(e.nativeEvent) ? 'virtual' : e.pointerType;
328
+ state.pointerType = e.pointerType;
299
329
 
300
330
  e.stopPropagation();
301
331
  if (!state.isPressed) {
@@ -308,7 +338,10 @@ export function usePress(props: PressHookProps): PressResult {
308
338
  focusWithoutScrolling(e.currentTarget);
309
339
  }
310
340
 
311
- disableTextSelection();
341
+ if (!allowTextSelectionOnPress) {
342
+ disableTextSelection(state.target);
343
+ }
344
+
312
345
  triggerPressStart(e, state.pointerType);
313
346
 
314
347
  addGlobalListener(document, 'pointermove', onPointerMove, false);
@@ -335,7 +368,8 @@ export function usePress(props: PressHookProps): PressResult {
335
368
  };
336
369
 
337
370
  pressProps.onPointerUp = (e) => {
338
- if (!e.currentTarget.contains(e.target as HTMLElement)) {
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') {
339
373
  return;
340
374
  }
341
375
 
@@ -343,7 +377,7 @@ export function usePress(props: PressHookProps): PressResult {
343
377
  // Safari on iOS sometimes fires pointerup events, even
344
378
  // when the touch isn't over the target, so double check.
345
379
  if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
346
- triggerPressUp(e, state.pointerType);
380
+ triggerPressUp(e, state.pointerType || e.pointerType);
347
381
  }
348
382
  };
349
383
 
@@ -363,6 +397,9 @@ export function usePress(props: PressHookProps): PressResult {
363
397
  } else if (state.isOverTarget) {
364
398
  state.isOverTarget = false;
365
399
  triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
400
+ if (propsRef.current.shouldCancelOnPointerExit) {
401
+ cancel(e);
402
+ }
366
403
  }
367
404
  };
368
405
 
@@ -379,7 +416,9 @@ export function usePress(props: PressHookProps): PressResult {
379
416
  state.activePointerId = null;
380
417
  state.pointerType = null;
381
418
  removeAllGlobalListeners();
382
- restoreTextSelection();
419
+ if (!allowTextSelectionOnPress) {
420
+ restoreTextSelection(state.target);
421
+ }
383
422
  }
384
423
  };
385
424
 
@@ -448,6 +487,9 @@ export function usePress(props: PressHookProps): PressResult {
448
487
  if (state.isPressed && !state.ignoreEmulatedMouseEvents) {
449
488
  state.isOverTarget = false;
450
489
  triggerPressEnd(e, state.pointerType, false);
490
+ if (propsRef.current.shouldCancelOnPointerExit) {
491
+ cancel(e);
492
+ }
451
493
  }
452
494
  };
453
495
 
@@ -507,7 +549,10 @@ export function usePress(props: PressHookProps): PressResult {
507
549
  focusWithoutScrolling(e.currentTarget);
508
550
  }
509
551
 
510
- disableTextSelection();
552
+ if (!allowTextSelectionOnPress) {
553
+ disableTextSelection(state.target);
554
+ }
555
+
511
556
  triggerPressStart(e, state.pointerType);
512
557
 
513
558
  addGlobalListener(window, 'scroll', onScroll, true);
@@ -532,6 +577,9 @@ export function usePress(props: PressHookProps): PressResult {
532
577
  } else if (state.isOverTarget) {
533
578
  state.isOverTarget = false;
534
579
  triggerPressEnd(e, state.pointerType, false);
580
+ if (propsRef.current.shouldCancelOnPointerExit) {
581
+ cancel(e);
582
+ }
535
583
  }
536
584
  };
537
585
 
@@ -557,7 +605,9 @@ export function usePress(props: PressHookProps): PressResult {
557
605
  state.activePointerId = null;
558
606
  state.isOverTarget = false;
559
607
  state.ignoreEmulatedMouseEvents = true;
560
- restoreTextSelection();
608
+ if (!allowTextSelectionOnPress) {
609
+ restoreTextSelection(state.target);
610
+ }
561
611
  removeAllGlobalListeners();
562
612
  };
563
613
 
@@ -578,7 +628,8 @@ export function usePress(props: PressHookProps): PressResult {
578
628
  currentTarget: state.target,
579
629
  shiftKey: false,
580
630
  ctrlKey: false,
581
- metaKey: false
631
+ metaKey: false,
632
+ altKey: false
582
633
  });
583
634
  }
584
635
  };
@@ -593,13 +644,17 @@ export function usePress(props: PressHookProps): PressResult {
593
644
  }
594
645
 
595
646
  return pressProps;
596
- }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners]);
647
+ }, [addGlobalListener, isDisabled, preventFocusOnPress, removeAllGlobalListeners, allowTextSelectionOnPress]);
597
648
 
598
649
  // Remove user-select: none in case component unmounts immediately after pressStart
599
650
  // eslint-disable-next-line arrow-body-style
600
651
  useEffect(() => {
601
- return () => restoreTextSelection();
602
- }, []);
652
+ return () => {
653
+ if (!allowTextSelectionOnPress) {
654
+ restoreTextSelection(ref.current.target);
655
+ }
656
+ };
657
+ }, [allowTextSelectionOnPress]);
603
658
 
604
659
  return {
605
660
  isPressed: isPressedProp || isPressed,
@@ -612,14 +667,14 @@ function isHTMLAnchorLink(target: HTMLElement): boolean {
612
667
  }
613
668
 
614
669
  function isValidKeyboardEvent(event: KeyboardEvent): boolean {
615
- const {key, target} = event;
670
+ const {key, code, target} = event;
616
671
  const element = target as HTMLElement;
617
672
  const {tagName, isContentEditable} = element;
618
673
  const role = element.getAttribute('role');
619
674
  // Accessibility for keyboards. Space and Enter only.
620
675
  // "Spacebar" is for IE 11
621
676
  return (
622
- (key === 'Enter' || key === ' ' || key === 'Spacebar') &&
677
+ (key === 'Enter' || key === ' ' || key === 'Spacebar' || code === 'Space') &&
623
678
  (tagName !== 'INPUT' &&
624
679
  tagName !== 'TEXTAREA' &&
625
680
  isContentEditable !== true) &&
@@ -658,7 +713,8 @@ function createEvent(target: HTMLElement, e: EventBase): EventBase {
658
713
  currentTarget: target,
659
714
  shiftKey: e.shiftKey,
660
715
  ctrlKey: e.ctrlKey,
661
- metaKey: e.metaKey
716
+ metaKey: e.metaKey,
717
+ altKey: e.altKey
662
718
  };
663
719
  }
664
720
 
@@ -713,7 +769,22 @@ function shouldPreventDefault(target: Element) {
713
769
  return !target.closest('[draggable="true"]');
714
770
  }
715
771
 
772
+ function shouldPreventDefaultKeyboard(target: Element) {
773
+ return !((target.tagName === 'INPUT' || target.tagName === 'BUTTON') && (target as HTMLButtonElement | HTMLInputElement).type === 'submit');
774
+ }
775
+
716
776
  function isVirtualPointerEvent(event: PointerEvent) {
717
777
  // If the pointer size is zero, then we assume it's from a screen reader.
718
- return event.width === 0 && event.height === 0;
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
+ );
719
790
  }
@@ -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
  }