@react-aria/interactions 3.22.5 → 3.24.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.
Files changed (90) hide show
  1. package/dist/Pressable.main.js +28 -4
  2. package/dist/Pressable.main.js.map +1 -1
  3. package/dist/Pressable.mjs +30 -6
  4. package/dist/Pressable.module.js +30 -6
  5. package/dist/Pressable.module.js.map +1 -1
  6. package/dist/createEventHandler.main.js +5 -1
  7. package/dist/createEventHandler.main.js.map +1 -1
  8. package/dist/createEventHandler.mjs +5 -1
  9. package/dist/createEventHandler.module.js +5 -1
  10. package/dist/createEventHandler.module.js.map +1 -1
  11. package/dist/focusSafely.main.js +40 -0
  12. package/dist/focusSafely.main.js.map +1 -0
  13. package/dist/focusSafely.mjs +35 -0
  14. package/dist/focusSafely.module.js +35 -0
  15. package/dist/focusSafely.module.js.map +1 -0
  16. package/dist/import.mjs +5 -1
  17. package/dist/main.js +9 -0
  18. package/dist/main.js.map +1 -1
  19. package/dist/module.js +5 -1
  20. package/dist/module.js.map +1 -1
  21. package/dist/textSelection.main.js +5 -3
  22. package/dist/textSelection.main.js.map +1 -1
  23. package/dist/textSelection.mjs +5 -3
  24. package/dist/textSelection.module.js +5 -3
  25. package/dist/textSelection.module.js.map +1 -1
  26. package/dist/types.d.ts +59 -25
  27. package/dist/types.d.ts.map +1 -1
  28. package/dist/useFocus.main.js +2 -1
  29. package/dist/useFocus.main.js.map +1 -1
  30. package/dist/useFocus.mjs +3 -2
  31. package/dist/useFocus.module.js +3 -2
  32. package/dist/useFocus.module.js.map +1 -1
  33. package/dist/useFocusVisible.main.js +9 -3
  34. package/dist/useFocusVisible.main.js.map +1 -1
  35. package/dist/useFocusVisible.mjs +9 -3
  36. package/dist/useFocusVisible.module.js +9 -3
  37. package/dist/useFocusVisible.module.js.map +1 -1
  38. package/dist/useFocusWithin.main.js +33 -4
  39. package/dist/useFocusWithin.main.js.map +1 -1
  40. package/dist/useFocusWithin.mjs +34 -5
  41. package/dist/useFocusWithin.module.js +34 -5
  42. package/dist/useFocusWithin.module.js.map +1 -1
  43. package/dist/useFocusable.main.js +112 -0
  44. package/dist/useFocusable.main.js.map +1 -0
  45. package/dist/useFocusable.mjs +100 -0
  46. package/dist/useFocusable.module.js +100 -0
  47. package/dist/useFocusable.module.js.map +1 -0
  48. package/dist/useHover.main.js +18 -3
  49. package/dist/useHover.main.js.map +1 -1
  50. package/dist/useHover.mjs +18 -3
  51. package/dist/useHover.module.js +18 -3
  52. package/dist/useHover.module.js.map +1 -1
  53. package/dist/useInteractOutside.main.js +6 -1
  54. package/dist/useInteractOutside.main.js.map +1 -1
  55. package/dist/useInteractOutside.mjs +6 -1
  56. package/dist/useInteractOutside.module.js +6 -1
  57. package/dist/useInteractOutside.module.js.map +1 -1
  58. package/dist/useLongPress.main.js +2 -0
  59. package/dist/useLongPress.main.js.map +1 -1
  60. package/dist/useLongPress.mjs +3 -1
  61. package/dist/useLongPress.module.js +3 -1
  62. package/dist/useLongPress.module.js.map +1 -1
  63. package/dist/usePress.main.js +97 -94
  64. package/dist/usePress.main.js.map +1 -1
  65. package/dist/usePress.mjs +98 -95
  66. package/dist/usePress.module.js +98 -95
  67. package/dist/usePress.module.js.map +1 -1
  68. package/dist/utils.main.js +57 -1
  69. package/dist/utils.main.js.map +1 -1
  70. package/dist/utils.mjs +55 -2
  71. package/dist/utils.module.js +55 -2
  72. package/dist/utils.module.js.map +1 -1
  73. package/package.json +7 -5
  74. package/src/Pressable.tsx +66 -6
  75. package/src/createEventHandler.ts +8 -1
  76. package/src/focusSafely.ts +45 -0
  77. package/src/index.ts +3 -0
  78. package/src/textSelection.ts +6 -4
  79. package/src/useFocus.ts +3 -3
  80. package/src/useFocusVisible.ts +14 -4
  81. package/src/useFocusWithin.ts +34 -5
  82. package/src/useFocusable.tsx +183 -0
  83. package/src/useHover.ts +17 -3
  84. package/src/useInteractOutside.ts +9 -3
  85. package/src/useLongPress.ts +8 -2
  86. package/src/usePress.ts +132 -131
  87. package/src/utils.ts +80 -1
  88. package/src/DOMPropsContext.ts +0 -39
  89. package/src/DOMPropsResponder.tsx +0 -47
  90. package/src/useDOMPropsResponder.ts +0 -27
package/src/usePress.ts CHANGED
@@ -15,10 +15,27 @@
15
15
  // NOTICE file in the root directory of this source tree.
16
16
  // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
17
17
 
18
- import {chain, focusWithoutScrolling, getOwnerDocument, getOwnerWindow, isMac, isVirtualClick, isVirtualPointerEvent, mergeProps, openLink, useEffectEvent, useGlobalListeners, useSyncRef} from '@react-aria/utils';
18
+ import {
19
+ chain,
20
+ focusWithoutScrolling,
21
+ getEventTarget,
22
+ getOwnerDocument,
23
+ getOwnerWindow,
24
+ isMac,
25
+ isVirtualClick,
26
+ isVirtualPointerEvent,
27
+ mergeProps,
28
+ nodeContains,
29
+ openLink,
30
+ useEffectEvent,
31
+ useGlobalListeners,
32
+ useSyncRef
33
+ } from '@react-aria/utils';
19
34
  import {disableTextSelection, restoreTextSelection} from './textSelection';
20
35
  import {DOMAttributes, FocusableElement, PressEvent as IPressEvent, PointerType, PressEvents, RefObject} from '@react-types/shared';
36
+ import {flushSync} from 'react-dom';
21
37
  import {PressResponderContext} from './context';
38
+ import {preventFocus} from './utils';
22
39
  import {TouchEvent as RTouchEvent, useContext, useEffect, useMemo, useRef, useState} from 'react';
23
40
 
24
41
  export interface PressProps extends PressEvents {
@@ -47,7 +64,6 @@ export interface PressHookProps extends PressProps {
47
64
  interface PressState {
48
65
  isPressed: boolean,
49
66
  ignoreEmulatedMouseEvents: boolean,
50
- ignoreClickAfterPress: boolean,
51
67
  didFirePressStart: boolean,
52
68
  isTriggeringEvent: boolean,
53
69
  activePointerId: any,
@@ -55,7 +71,8 @@ interface PressState {
55
71
  isOverTarget: boolean,
56
72
  pointerType: PointerType | null,
57
73
  userSelect?: string,
58
- metaKeyEvents?: Map<string, KeyboardEvent>
74
+ metaKeyEvents?: Map<string, KeyboardEvent>,
75
+ disposables: Array<() => void>
59
76
  }
60
77
 
61
78
  interface EventBase {
@@ -167,13 +184,13 @@ export function usePress(props: PressHookProps): PressResult {
167
184
  let ref = useRef<PressState>({
168
185
  isPressed: false,
169
186
  ignoreEmulatedMouseEvents: false,
170
- ignoreClickAfterPress: false,
171
187
  didFirePressStart: false,
172
188
  isTriggeringEvent: false,
173
189
  activePointerId: null,
174
190
  target: null,
175
191
  isOverTarget: false,
176
- pointerType: null
192
+ pointerType: null,
193
+ disposables: []
177
194
  });
178
195
 
179
196
  let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
@@ -208,7 +225,6 @@ export function usePress(props: PressHookProps): PressResult {
208
225
  return false;
209
226
  }
210
227
 
211
- state.ignoreClickAfterPress = true;
212
228
  state.didFirePressStart = false;
213
229
  state.isTriggeringEvent = true;
214
230
 
@@ -255,7 +271,7 @@ export function usePress(props: PressHookProps): PressResult {
255
271
  let cancel = useEffectEvent((e: EventBase) => {
256
272
  let state = ref.current;
257
273
  if (state.isPressed && state.target) {
258
- if (state.isOverTarget && state.pointerType != null) {
274
+ if (state.didFirePressStart && state.pointerType != null) {
259
275
  triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
260
276
  }
261
277
  state.isPressed = false;
@@ -266,6 +282,10 @@ export function usePress(props: PressHookProps): PressResult {
266
282
  if (!allowTextSelectionOnPress) {
267
283
  restoreTextSelection(state.target);
268
284
  }
285
+ for (let dispose of state.disposables) {
286
+ dispose();
287
+ }
288
+ state.disposables = [];
269
289
  }
270
290
  });
271
291
 
@@ -279,8 +299,8 @@ export function usePress(props: PressHookProps): PressResult {
279
299
  let state = ref.current;
280
300
  let pressProps: DOMAttributes = {
281
301
  onKeyDown(e) {
282
- if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && e.currentTarget.contains(e.target as Element)) {
283
- if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
302
+ if (isValidKeyboardEvent(e.nativeEvent, e.currentTarget) && nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
303
+ if (shouldPreventDefaultKeyboard(getEventTarget(e.nativeEvent), e.key)) {
284
304
  e.preventDefault();
285
305
  }
286
306
 
@@ -291,6 +311,7 @@ export function usePress(props: PressHookProps): PressResult {
291
311
  if (!state.isPressed && !e.repeat) {
292
312
  state.target = e.currentTarget;
293
313
  state.isPressed = true;
314
+ state.pointerType = 'keyboard';
294
315
  shouldStopPropagation = triggerPressStart(e, 'keyboard');
295
316
 
296
317
  // Focus may move before the key up event, so register the event on the document
@@ -298,7 +319,7 @@ export function usePress(props: PressHookProps): PressResult {
298
319
  // before stopPropagation from useKeyboard on a child element may happen and thus we can still call triggerPress for the parent element.
299
320
  let originalTarget = e.currentTarget;
300
321
  let pressUp = (e) => {
301
- if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && originalTarget.contains(e.target as Element) && state.target) {
322
+ if (isValidKeyboardEvent(e, originalTarget) && !e.repeat && nodeContains(originalTarget, getEventTarget(e)) && state.target) {
302
323
  triggerPressUp(createEvent(state.target, e), 'keyboard');
303
324
  }
304
325
  };
@@ -325,7 +346,7 @@ export function usePress(props: PressHookProps): PressResult {
325
346
  }
326
347
  },
327
348
  onClick(e) {
328
- if (e && !e.currentTarget.contains(e.target as Element)) {
349
+ if (e && !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
329
350
  return;
330
351
  }
331
352
 
@@ -334,23 +355,22 @@ export function usePress(props: PressHookProps): PressResult {
334
355
  if (isDisabled) {
335
356
  e.preventDefault();
336
357
  }
337
-
358
+
338
359
  // If triggered from a screen reader or by using element.click(),
339
360
  // trigger as if it were a keyboard click.
340
- if (!state.ignoreClickAfterPress && !state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
341
- // Ensure the element receives focus (VoiceOver on iOS does not do this)
342
- if (!isDisabled && !preventFocusOnPress) {
343
- focusWithoutScrolling(e.currentTarget);
344
- }
345
-
361
+ if (!state.ignoreEmulatedMouseEvents && !state.isPressed && (state.pointerType === 'virtual' || isVirtualClick(e.nativeEvent))) {
346
362
  let stopPressStart = triggerPressStart(e, 'virtual');
347
363
  let stopPressUp = triggerPressUp(e, 'virtual');
348
364
  let stopPressEnd = triggerPressEnd(e, 'virtual');
349
365
  shouldStopPropagation = stopPressStart && stopPressUp && stopPressEnd;
366
+ } else if (state.isPressed && state.pointerType !== 'keyboard') {
367
+ let pointerType = state.pointerType || (e.nativeEvent as PointerEvent).pointerType as PointerType || 'virtual';
368
+ shouldStopPropagation = triggerPressEnd(createEvent(e.currentTarget, e), pointerType, true);
369
+ state.isOverTarget = false;
370
+ cancel(e);
350
371
  }
351
372
 
352
373
  state.ignoreEmulatedMouseEvents = false;
353
- state.ignoreClickAfterPress = false;
354
374
  if (shouldStopPropagation) {
355
375
  e.stopPropagation();
356
376
  }
@@ -360,18 +380,18 @@ export function usePress(props: PressHookProps): PressResult {
360
380
 
361
381
  let onKeyUp = (e: KeyboardEvent) => {
362
382
  if (state.isPressed && state.target && isValidKeyboardEvent(e, state.target)) {
363
- if (shouldPreventDefaultKeyboard(e.target as Element, e.key)) {
383
+ if (shouldPreventDefaultKeyboard(getEventTarget(e), e.key)) {
364
384
  e.preventDefault();
365
385
  }
366
386
 
367
- let target = e.target as Element;
368
- triggerPressEnd(createEvent(state.target, e), 'keyboard', state.target.contains(target));
387
+ let target = getEventTarget(e);
388
+ triggerPressEnd(createEvent(state.target, e), 'keyboard', nodeContains(state.target, getEventTarget(e)));
369
389
  removeAllGlobalListeners();
370
390
 
371
391
  // If a link was triggered with a key other than Enter, open the URL ourselves.
372
392
  // This means the link has a role override, and the default browser behavior
373
393
  // only applies when using the Enter key.
374
- if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && state.target.contains(target) && !e[LINK_CLICKED]) {
394
+ if (e.key !== 'Enter' && isHTMLAnchorLink(state.target) && nodeContains(state.target, target) && !e[LINK_CLICKED]) {
375
395
  // Store a hidden property on the event so we only trigger link click once,
376
396
  // even if there are multiple usePress instances attached to the element.
377
397
  e[LINK_CLICKED] = true;
@@ -395,7 +415,7 @@ export function usePress(props: PressHookProps): PressResult {
395
415
  if (typeof PointerEvent !== 'undefined') {
396
416
  pressProps.onPointerDown = (e) => {
397
417
  // Only handle left clicks, and ignore events that bubbled through portals.
398
- if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
418
+ if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
399
419
  return;
400
420
  }
401
421
 
@@ -408,12 +428,6 @@ export function usePress(props: PressHookProps): PressResult {
408
428
  return;
409
429
  }
410
430
 
411
- // Due to browser inconsistencies, especially on mobile browsers, we prevent
412
- // default on pointer down and handle focusing the pressable element ourselves.
413
- if (shouldPreventDefaultDown(e.currentTarget as Element)) {
414
- e.preventDefault();
415
- }
416
-
417
431
  state.pointerType = e.pointerType;
418
432
 
419
433
  let shouldStopPropagation = true;
@@ -421,11 +435,7 @@ export function usePress(props: PressHookProps): PressResult {
421
435
  state.isPressed = true;
422
436
  state.isOverTarget = true;
423
437
  state.activePointerId = e.pointerId;
424
- state.target = e.currentTarget;
425
-
426
- if (!isDisabled && !preventFocusOnPress) {
427
- focusWithoutScrolling(e.currentTarget);
428
- }
438
+ state.target = e.currentTarget as FocusableElement;
429
439
 
430
440
  if (!allowTextSelectionOnPress) {
431
441
  disableTextSelection(state.target);
@@ -433,7 +443,13 @@ export function usePress(props: PressHookProps): PressResult {
433
443
 
434
444
  shouldStopPropagation = triggerPressStart(e, state.pointerType);
435
445
 
436
- addGlobalListener(getOwnerDocument(e.currentTarget), 'pointermove', onPointerMove, false);
446
+ // Release pointer capture so that touch interactions can leave the original target.
447
+ // This enables onPointerLeave and onPointerEnter to fire.
448
+ let target = getEventTarget(e.nativeEvent);
449
+ if ('releasePointerCapture' in target) {
450
+ target.releasePointerCapture(e.pointerId);
451
+ }
452
+
437
453
  addGlobalListener(getOwnerDocument(e.currentTarget), 'pointerup', onPointerUp, false);
438
454
  addGlobalListener(getOwnerDocument(e.currentTarget), 'pointercancel', onPointerCancel, false);
439
455
  }
@@ -444,16 +460,16 @@ export function usePress(props: PressHookProps): PressResult {
444
460
  };
445
461
 
446
462
  pressProps.onMouseDown = (e) => {
447
- if (!e.currentTarget.contains(e.target as Element)) {
463
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
448
464
  return;
449
465
  }
450
466
 
451
467
  if (e.button === 0) {
452
- // Chrome and Firefox on touch Windows devices require mouse down events
453
- // to be canceled in addition to pointer events, or an extra asynchronous
454
- // focus event will be fired.
455
- if (shouldPreventDefaultDown(e.currentTarget as Element)) {
456
- e.preventDefault();
468
+ if (preventFocusOnPress) {
469
+ let dispose = preventFocus(e.target as FocusableElement);
470
+ if (dispose) {
471
+ state.disposables.push(dispose);
472
+ }
457
473
  }
458
474
 
459
475
  e.stopPropagation();
@@ -462,32 +478,25 @@ export function usePress(props: PressHookProps): PressResult {
462
478
 
463
479
  pressProps.onPointerUp = (e) => {
464
480
  // iOS fires pointerup with zero width and height, so check the pointerType recorded during pointerdown.
465
- if (!e.currentTarget.contains(e.target as Element) || state.pointerType === 'virtual') {
481
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent)) || state.pointerType === 'virtual') {
466
482
  return;
467
483
  }
468
484
 
469
485
  // Only handle left clicks
470
- // Safari on iOS sometimes fires pointerup events, even
471
- // when the touch isn't over the target, so double check.
472
- if (e.button === 0 && isOverTarget(e, e.currentTarget)) {
486
+ if (e.button === 0) {
473
487
  triggerPressUp(e, state.pointerType || e.pointerType);
474
488
  }
475
489
  };
476
490
 
477
- // Safari on iOS < 13.2 does not implement pointerenter/pointerleave events correctly.
478
- // Use pointer move events instead to implement our own hit testing.
479
- // See https://bugs.webkit.org/show_bug.cgi?id=199803
480
- let onPointerMove = (e: PointerEvent) => {
481
- if (e.pointerId !== state.activePointerId) {
482
- return;
491
+ pressProps.onPointerEnter = (e) => {
492
+ if (e.pointerId === state.activePointerId && state.target && !state.isOverTarget && state.pointerType != null) {
493
+ state.isOverTarget = true;
494
+ triggerPressStart(createEvent(state.target, e), state.pointerType);
483
495
  }
496
+ };
484
497
 
485
- if (state.target && isOverTarget(e, state.target)) {
486
- if (!state.isOverTarget && state.pointerType != null) {
487
- state.isOverTarget = true;
488
- triggerPressStart(createEvent(state.target, e), state.pointerType);
489
- }
490
- } else if (state.target && state.isOverTarget && state.pointerType != null) {
498
+ pressProps.onPointerLeave = (e) => {
499
+ if (e.pointerId === state.activePointerId && state.target && state.isOverTarget && state.pointerType != null) {
491
500
  state.isOverTarget = false;
492
501
  triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
493
502
  cancelOnPointerExit(e);
@@ -496,39 +505,38 @@ export function usePress(props: PressHookProps): PressResult {
496
505
 
497
506
  let onPointerUp = (e: PointerEvent) => {
498
507
  if (e.pointerId === state.activePointerId && state.isPressed && e.button === 0 && state.target) {
499
- if (isOverTarget(e, state.target) && state.pointerType != null) {
500
- triggerPressEnd(createEvent(state.target, e), state.pointerType);
501
- } else if (state.isOverTarget && state.pointerType != null) {
502
- triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
508
+ if (nodeContains(state.target, getEventTarget(e)) && state.pointerType != null) {
509
+ // Wait for onClick to fire onPress. This avoids browser issues when the DOM
510
+ // is mutated between onPointerUp and onClick, and is more compatible with third party libraries.
511
+ // https://github.com/adobe/react-spectrum/issues/1513
512
+ // https://issues.chromium.org/issues/40732224
513
+ // However, iOS and Android do not focus or fire onClick after a long press.
514
+ // We work around this by triggering a click ourselves after a timeout.
515
+ // This timeout is canceled during the click event in case the real one fires first.
516
+ // The timeout must be at least 32ms, because Safari on iOS delays the click event on
517
+ // non-form elements without certain ARIA roles (for hover emulation).
518
+ // https://github.com/WebKit/WebKit/blob/dccfae42bb29bd4bdef052e469f604a9387241c0/Source/WebKit/WebProcess/WebPage/ios/WebPageIOS.mm#L875-L892
519
+ let clicked = false;
520
+ let timeout = setTimeout(() => {
521
+ if (state.isPressed && state.target instanceof HTMLElement) {
522
+ if (clicked) {
523
+ cancel(e);
524
+ } else {
525
+ focusWithoutScrolling(state.target);
526
+ state.target.click();
527
+ }
528
+ }
529
+ }, 80);
530
+ // Use a capturing listener to track if a click occurred.
531
+ // If stopPropagation is called it may never reach our handler.
532
+ addGlobalListener(e.currentTarget as Document, 'click', () => clicked = true, true);
533
+ state.disposables.push(() => clearTimeout(timeout));
534
+ } else {
535
+ cancel(e);
503
536
  }
504
537
 
505
- state.isPressed = false;
538
+ // Ignore subsequent onPointerLeave event before onClick on touch devices.
506
539
  state.isOverTarget = false;
507
- state.activePointerId = null;
508
- state.pointerType = null;
509
- removeAllGlobalListeners();
510
- if (!allowTextSelectionOnPress) {
511
- restoreTextSelection(state.target);
512
- }
513
-
514
- // Prevent subsequent touchend event from triggering onClick on unrelated elements on Android. See below.
515
- // Both 'touch' and 'pen' pointerTypes trigger onTouchEnd, but 'mouse' does not.
516
- if ('ontouchend' in state.target && e.pointerType !== 'mouse') {
517
- addGlobalListener(state.target, 'touchend', onTouchEnd, {once: true});
518
- }
519
- }
520
- };
521
-
522
- // This is a workaround for an Android Chrome/Firefox issue where click events are fired on an incorrect element
523
- // if the original target is removed during onPointerUp (before onClick).
524
- // https://github.com/adobe/react-spectrum/issues/1513
525
- // https://issues.chromium.org/issues/40732224
526
- // Note: this event must be registered directly on the element, not via React props in order to work.
527
- // https://github.com/facebook/react/issues/9809
528
- let onTouchEnd = (e: TouchEvent) => {
529
- // Don't preventDefault if we actually want the default (e.g. submit/link click).
530
- if (shouldPreventDefaultUp(e.currentTarget as Element)) {
531
- e.preventDefault();
532
540
  }
533
541
  };
534
542
 
@@ -537,7 +545,7 @@ export function usePress(props: PressHookProps): PressResult {
537
545
  };
538
546
 
539
547
  pressProps.onDragStart = (e) => {
540
- if (!e.currentTarget.contains(e.target as Element)) {
548
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
541
549
  return;
542
550
  }
543
551
 
@@ -545,18 +553,15 @@ export function usePress(props: PressHookProps): PressResult {
545
553
  cancel(e);
546
554
  };
547
555
  } else {
556
+ // NOTE: this fallback branch is almost entirely used by unit tests.
557
+ // All browsers now support pointer events, but JSDOM still does not.
558
+
548
559
  pressProps.onMouseDown = (e) => {
549
560
  // Only handle left clicks
550
- if (e.button !== 0 || !e.currentTarget.contains(e.target as Element)) {
561
+ if (e.button !== 0 || !nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
551
562
  return;
552
563
  }
553
564
 
554
- // Due to browser inconsistencies, especially on mobile browsers, we prevent
555
- // default on mouse down and handle focusing the pressable element ourselves.
556
- if (shouldPreventDefaultDown(e.currentTarget)) {
557
- e.preventDefault();
558
- }
559
-
560
565
  if (state.ignoreEmulatedMouseEvents) {
561
566
  e.stopPropagation();
562
567
  return;
@@ -567,20 +572,24 @@ export function usePress(props: PressHookProps): PressResult {
567
572
  state.target = e.currentTarget;
568
573
  state.pointerType = isVirtualClick(e.nativeEvent) ? 'virtual' : 'mouse';
569
574
 
570
- if (!isDisabled && !preventFocusOnPress) {
571
- focusWithoutScrolling(e.currentTarget);
572
- }
573
-
574
- let shouldStopPropagation = triggerPressStart(e, state.pointerType);
575
+ // Flush sync so that focus moved during react re-renders occurs before we yield back to the browser.
576
+ let shouldStopPropagation = flushSync(() => triggerPressStart(e, state.pointerType!));
575
577
  if (shouldStopPropagation) {
576
578
  e.stopPropagation();
577
579
  }
578
580
 
581
+ if (preventFocusOnPress) {
582
+ let dispose = preventFocus(e.target as FocusableElement);
583
+ if (dispose) {
584
+ state.disposables.push(dispose);
585
+ }
586
+ }
587
+
579
588
  addGlobalListener(getOwnerDocument(e.currentTarget), 'mouseup', onMouseUp, false);
580
589
  };
581
590
 
582
591
  pressProps.onMouseEnter = (e) => {
583
- if (!e.currentTarget.contains(e.target as Element)) {
592
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
584
593
  return;
585
594
  }
586
595
 
@@ -596,7 +605,7 @@ export function usePress(props: PressHookProps): PressResult {
596
605
  };
597
606
 
598
607
  pressProps.onMouseLeave = (e) => {
599
- if (!e.currentTarget.contains(e.target as Element)) {
608
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
600
609
  return;
601
610
  }
602
611
 
@@ -613,7 +622,7 @@ export function usePress(props: PressHookProps): PressResult {
613
622
  };
614
623
 
615
624
  pressProps.onMouseUp = (e) => {
616
- if (!e.currentTarget.contains(e.target as Element)) {
625
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
617
626
  return;
618
627
  }
619
628
 
@@ -628,25 +637,23 @@ export function usePress(props: PressHookProps): PressResult {
628
637
  return;
629
638
  }
630
639
 
631
- state.isPressed = false;
632
- removeAllGlobalListeners();
633
-
634
640
  if (state.ignoreEmulatedMouseEvents) {
635
641
  state.ignoreEmulatedMouseEvents = false;
636
642
  return;
637
643
  }
638
644
 
639
- if (state.target && isOverTarget(e, state.target) && state.pointerType != null) {
640
- triggerPressEnd(createEvent(state.target, e), state.pointerType);
641
- } else if (state.target && state.isOverTarget && state.pointerType != null) {
642
- triggerPressEnd(createEvent(state.target, e), state.pointerType, false);
645
+ if (state.target && state.target.contains(e.target as Element) && state.pointerType != null) {
646
+ // Wait for onClick to fire onPress. This avoids browser issues when the DOM
647
+ // is mutated between onMouseUp and onClick, and is more compatible with third party libraries.
648
+ } else {
649
+ cancel(e);
643
650
  }
644
651
 
645
652
  state.isOverTarget = false;
646
653
  };
647
654
 
648
655
  pressProps.onTouchStart = (e) => {
649
- if (!e.currentTarget.contains(e.target as Element)) {
656
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
650
657
  return;
651
658
  }
652
659
 
@@ -661,12 +668,6 @@ export function usePress(props: PressHookProps): PressResult {
661
668
  state.target = e.currentTarget;
662
669
  state.pointerType = 'touch';
663
670
 
664
- // Due to browser inconsistencies, especially on mobile browsers, we prevent default
665
- // on the emulated mouse event and handle focusing the pressable element ourselves.
666
- if (!isDisabled && !preventFocusOnPress) {
667
- focusWithoutScrolling(e.currentTarget);
668
- }
669
-
670
671
  if (!allowTextSelectionOnPress) {
671
672
  disableTextSelection(state.target);
672
673
  }
@@ -680,7 +681,7 @@ export function usePress(props: PressHookProps): PressResult {
680
681
  };
681
682
 
682
683
  pressProps.onTouchMove = (e) => {
683
- if (!e.currentTarget.contains(e.target as Element)) {
684
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
684
685
  return;
685
686
  }
686
687
 
@@ -708,7 +709,7 @@ export function usePress(props: PressHookProps): PressResult {
708
709
  };
709
710
 
710
711
  pressProps.onTouchEnd = (e) => {
711
- if (!e.currentTarget.contains(e.target as Element)) {
712
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
712
713
  return;
713
714
  }
714
715
 
@@ -741,7 +742,7 @@ export function usePress(props: PressHookProps): PressResult {
741
742
  };
742
743
 
743
744
  pressProps.onTouchCancel = (e) => {
744
- if (!e.currentTarget.contains(e.target as Element)) {
745
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
745
746
  return;
746
747
  }
747
748
 
@@ -752,7 +753,7 @@ export function usePress(props: PressHookProps): PressResult {
752
753
  };
753
754
 
754
755
  let onScroll = (e: Event) => {
755
- if (state.isPressed && (e.target as Element).contains(state.target)) {
756
+ if (state.isPressed && nodeContains(getEventTarget(e), state.target)) {
756
757
  cancel({
757
758
  currentTarget: state.target,
758
759
  shiftKey: false,
@@ -764,7 +765,7 @@ export function usePress(props: PressHookProps): PressResult {
764
765
  };
765
766
 
766
767
  pressProps.onDragStart = (e) => {
767
- if (!e.currentTarget.contains(e.target as Element)) {
768
+ if (!nodeContains(e.currentTarget, getEventTarget(e.nativeEvent))) {
768
769
  return;
769
770
  }
770
771
 
@@ -787,13 +788,18 @@ export function usePress(props: PressHookProps): PressResult {
787
788
  ]);
788
789
 
789
790
  // Remove user-select: none in case component unmounts immediately after pressStart
790
-
791
+
791
792
  useEffect(() => {
793
+ let state = ref.current;
792
794
  return () => {
793
795
  if (!allowTextSelectionOnPress) {
794
- // eslint-disable-next-line react-hooks/exhaustive-deps
795
- restoreTextSelection(ref.current.target ?? undefined);
796
+
797
+ restoreTextSelection(state.target ?? undefined);
796
798
  }
799
+ for (let dispose of state.disposables) {
800
+ dispose();
801
+ }
802
+ state.disposables = [];
797
803
  };
798
804
  }, [allowTextSelectionOnPress]);
799
805
 
@@ -933,11 +939,6 @@ function isOverTarget(point: EventPoint, target: Element) {
933
939
  return areRectanglesOverlapping(rect, pointRect);
934
940
  }
935
941
 
936
- function shouldPreventDefaultDown(target: Element) {
937
- // We cannot prevent default if the target is a draggable element.
938
- return !(target instanceof HTMLElement) || !target.hasAttribute('draggable');
939
- }
940
-
941
942
  function shouldPreventDefaultUp(target: Element) {
942
943
  if (target instanceof HTMLInputElement) {
943
944
  return false;
package/src/utils.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
+ import {FocusableElement} from '@react-types/shared';
14
+ import {focusWithoutScrolling, getOwnerWindow, isFocusable, useEffectEvent, useLayoutEffect} from '@react-aria/utils';
13
15
  import {FocusEvent as ReactFocusEvent, useCallback, useRef} from 'react';
14
- import {useEffectEvent, useLayoutEffect} from '@react-aria/utils';
15
16
 
16
17
  export class SyntheticFocusEvent<Target = Element> implements ReactFocusEvent<Target> {
17
18
  nativeEvent: FocusEvent;
@@ -128,3 +129,81 @@ export function useSyntheticBlurEvent<Target = Element>(onBlur: (e: ReactFocusEv
128
129
  }
129
130
  }, [dispatchBlur]);
130
131
  }
132
+
133
+ export let ignoreFocusEvent = false;
134
+
135
+ /**
136
+ * This function prevents the next focus event fired on `target`, without using `event.preventDefault()`.
137
+ * It works by waiting for the series of focus events to occur, and reverts focus back to where it was before.
138
+ * It also makes these events mostly non-observable by using a capturing listener on the window and stopping propagation.
139
+ */
140
+ export function preventFocus(target: FocusableElement | null) {
141
+ // The browser will focus the nearest focusable ancestor of our target.
142
+ while (target && !isFocusable(target)) {
143
+ target = target.parentElement;
144
+ }
145
+
146
+ let window = getOwnerWindow(target);
147
+ let activeElement = window.document.activeElement as FocusableElement | null;
148
+ if (!activeElement || activeElement === target) {
149
+ return;
150
+ }
151
+
152
+ ignoreFocusEvent = true;
153
+ let isRefocusing = false;
154
+ let onBlur = (e: FocusEvent) => {
155
+ if (e.target === activeElement || isRefocusing) {
156
+ e.stopImmediatePropagation();
157
+ }
158
+ };
159
+
160
+ let onFocusOut = (e: FocusEvent) => {
161
+ if (e.target === activeElement || isRefocusing) {
162
+ e.stopImmediatePropagation();
163
+
164
+ // If there was no focusable ancestor, we don't expect a focus event.
165
+ // Re-focus the original active element here.
166
+ if (!target && !isRefocusing) {
167
+ isRefocusing = true;
168
+ focusWithoutScrolling(activeElement);
169
+ cleanup();
170
+ }
171
+ }
172
+ };
173
+
174
+ let onFocus = (e: FocusEvent) => {
175
+ if (e.target === target || isRefocusing) {
176
+ e.stopImmediatePropagation();
177
+ }
178
+ };
179
+
180
+ let onFocusIn = (e: FocusEvent) => {
181
+ if (e.target === target || isRefocusing) {
182
+ e.stopImmediatePropagation();
183
+
184
+ if (!isRefocusing) {
185
+ isRefocusing = true;
186
+ focusWithoutScrolling(activeElement);
187
+ cleanup();
188
+ }
189
+ }
190
+ };
191
+
192
+ window.addEventListener('blur', onBlur, true);
193
+ window.addEventListener('focusout', onFocusOut, true);
194
+ window.addEventListener('focusin', onFocusIn, true);
195
+ window.addEventListener('focus', onFocus, true);
196
+
197
+ let cleanup = () => {
198
+ cancelAnimationFrame(raf);
199
+ window.removeEventListener('blur', onBlur, true);
200
+ window.removeEventListener('focusout', onFocusOut, true);
201
+ window.removeEventListener('focusin', onFocusIn, true);
202
+ window.removeEventListener('focus', onFocus, true);
203
+ ignoreFocusEvent = false;
204
+ isRefocusing = false;
205
+ };
206
+
207
+ let raf = requestAnimationFrame(cleanup);
208
+ return cleanup;
209
+ }