@react-aria/interactions 3.23.0 → 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 (84) 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/focusSafely.main.js +40 -0
  7. package/dist/focusSafely.main.js.map +1 -0
  8. package/dist/focusSafely.mjs +35 -0
  9. package/dist/focusSafely.module.js +35 -0
  10. package/dist/focusSafely.module.js.map +1 -0
  11. package/dist/import.mjs +5 -1
  12. package/dist/main.js +9 -0
  13. package/dist/main.js.map +1 -1
  14. package/dist/module.js +5 -1
  15. package/dist/module.js.map +1 -1
  16. package/dist/textSelection.main.js +5 -3
  17. package/dist/textSelection.main.js.map +1 -1
  18. package/dist/textSelection.mjs +5 -3
  19. package/dist/textSelection.module.js +5 -3
  20. package/dist/textSelection.module.js.map +1 -1
  21. package/dist/types.d.ts +59 -25
  22. package/dist/types.d.ts.map +1 -1
  23. package/dist/useFocus.main.js +2 -1
  24. package/dist/useFocus.main.js.map +1 -1
  25. package/dist/useFocus.mjs +3 -2
  26. package/dist/useFocus.module.js +3 -2
  27. package/dist/useFocus.module.js.map +1 -1
  28. package/dist/useFocusVisible.main.js +9 -3
  29. package/dist/useFocusVisible.main.js.map +1 -1
  30. package/dist/useFocusVisible.mjs +9 -3
  31. package/dist/useFocusVisible.module.js +9 -3
  32. package/dist/useFocusVisible.module.js.map +1 -1
  33. package/dist/useFocusWithin.main.js +33 -4
  34. package/dist/useFocusWithin.main.js.map +1 -1
  35. package/dist/useFocusWithin.mjs +34 -5
  36. package/dist/useFocusWithin.module.js +34 -5
  37. package/dist/useFocusWithin.module.js.map +1 -1
  38. package/dist/useFocusable.main.js +112 -0
  39. package/dist/useFocusable.main.js.map +1 -0
  40. package/dist/useFocusable.mjs +100 -0
  41. package/dist/useFocusable.module.js +100 -0
  42. package/dist/useFocusable.module.js.map +1 -0
  43. package/dist/useHover.main.js +18 -3
  44. package/dist/useHover.main.js.map +1 -1
  45. package/dist/useHover.mjs +18 -3
  46. package/dist/useHover.module.js +18 -3
  47. package/dist/useHover.module.js.map +1 -1
  48. package/dist/useInteractOutside.main.js +6 -1
  49. package/dist/useInteractOutside.main.js.map +1 -1
  50. package/dist/useInteractOutside.mjs +6 -1
  51. package/dist/useInteractOutside.module.js +6 -1
  52. package/dist/useInteractOutside.module.js.map +1 -1
  53. package/dist/useLongPress.main.js +2 -0
  54. package/dist/useLongPress.main.js.map +1 -1
  55. package/dist/useLongPress.mjs +3 -1
  56. package/dist/useLongPress.module.js +3 -1
  57. package/dist/useLongPress.module.js.map +1 -1
  58. package/dist/usePress.main.js +85 -80
  59. package/dist/usePress.main.js.map +1 -1
  60. package/dist/usePress.mjs +86 -81
  61. package/dist/usePress.module.js +86 -81
  62. package/dist/usePress.module.js.map +1 -1
  63. package/dist/utils.main.js +57 -1
  64. package/dist/utils.main.js.map +1 -1
  65. package/dist/utils.mjs +55 -2
  66. package/dist/utils.module.js +55 -2
  67. package/dist/utils.module.js.map +1 -1
  68. package/package.json +5 -4
  69. package/src/Pressable.tsx +66 -6
  70. package/src/focusSafely.ts +45 -0
  71. package/src/index.ts +3 -0
  72. package/src/textSelection.ts +6 -4
  73. package/src/useFocus.ts +3 -3
  74. package/src/useFocusVisible.ts +14 -4
  75. package/src/useFocusWithin.ts +34 -5
  76. package/src/useFocusable.tsx +183 -0
  77. package/src/useHover.ts +17 -3
  78. package/src/useInteractOutside.ts +9 -3
  79. package/src/useLongPress.ts +8 -2
  80. package/src/usePress.ts +117 -115
  81. package/src/utils.ts +80 -1
  82. package/src/DOMPropsContext.ts +0 -39
  83. package/src/DOMPropsResponder.tsx +0 -47
  84. package/src/useDOMPropsResponder.ts +0 -27
package/src/Pressable.tsx CHANGED
@@ -10,22 +10,82 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {DOMAttributes} from '@react-types/shared';
14
- import {mergeProps, useObjectRef} from '@react-aria/utils';
13
+ import {DOMAttributes, FocusableElement} from '@react-types/shared';
14
+ import {getOwnerWindow, isFocusable, mergeProps, mergeRefs, useObjectRef} from '@react-aria/utils';
15
15
  import {PressProps, usePress} from './usePress';
16
- import React, {ForwardedRef, ReactElement} from 'react';
16
+ import React, {ForwardedRef, ReactElement, useEffect} from 'react';
17
+ import {useFocusable} from './useFocusable';
17
18
 
18
19
  interface PressableProps extends PressProps {
19
20
  children: ReactElement<DOMAttributes, string>
20
21
  }
21
22
 
22
- export const Pressable = React.forwardRef(({children, ...props}: PressableProps, ref: ForwardedRef<HTMLElement>) => {
23
+ export const Pressable = React.forwardRef(({children, ...props}: PressableProps, ref: ForwardedRef<FocusableElement>) => {
23
24
  ref = useObjectRef(ref);
24
25
  let {pressProps} = usePress({...props, ref});
26
+ let {focusableProps} = useFocusable(props, ref);
25
27
  let child = React.Children.only(children);
28
+
29
+ useEffect(() => {
30
+ let el = ref.current;
31
+ if (!el || !(el instanceof getOwnerWindow(el).Element)) {
32
+ console.error('<Pressable> child must forward its ref to a DOM element.');
33
+ return;
34
+ }
35
+
36
+ if (!isFocusable(el)) {
37
+ console.warn('<Pressable> child must be focusable. Please ensure the tabIndex prop is passed through.');
38
+ return;
39
+ }
40
+
41
+ if (
42
+ el.localName !== 'button' &&
43
+ el.localName !== 'input' &&
44
+ el.localName !== 'select' &&
45
+ el.localName !== 'textarea' &&
46
+ el.localName !== 'a' &&
47
+ el.localName !== 'area' &&
48
+ el.localName !== 'summary'
49
+ ) {
50
+ let role = el.getAttribute('role');
51
+ if (!role) {
52
+ console.warn('<Pressable> child must have an interactive ARIA role.');
53
+ } else if (
54
+ // https://w3c.github.io/aria/#widget_roles
55
+ role !== 'application' &&
56
+ role !== 'button' &&
57
+ role !== 'checkbox' &&
58
+ role !== 'combobox' &&
59
+ role !== 'gridcell' &&
60
+ role !== 'link' &&
61
+ role !== 'menuitem' &&
62
+ role !== 'menuitemcheckbox' &&
63
+ role !== 'menuitemradio' &&
64
+ role !== 'option' &&
65
+ role !== 'radio' &&
66
+ role !== 'searchbox' &&
67
+ role !== 'separator' &&
68
+ role !== 'slider' &&
69
+ role !== 'spinbutton' &&
70
+ role !== 'switch' &&
71
+ role !== 'tab' &&
72
+ role !== 'textbox' &&
73
+ role !== 'treeitem'
74
+ ) {
75
+ console.warn(`<Pressable> child must have an interactive ARIA role. Got "${role}".`);
76
+ }
77
+ }
78
+ }, [ref]);
79
+
80
+ // @ts-ignore
81
+ let childRef = parseInt(React.version, 10) < 19 ? child.ref : child.props.ref;
82
+
26
83
  return React.cloneElement(
27
84
  child,
28
- // @ts-ignore
29
- {ref, ...mergeProps(child.props, pressProps)}
85
+ {
86
+ ...mergeProps(pressProps, focusableProps, child.props),
87
+ // @ts-ignore
88
+ ref: mergeRefs(childRef, ref)
89
+ }
30
90
  );
31
91
  });
@@ -0,0 +1,45 @@
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 {FocusableElement} from '@react-types/shared';
14
+ import {
15
+ focusWithoutScrolling,
16
+ getActiveElement,
17
+ getOwnerDocument,
18
+ runAfterTransition
19
+ } from '@react-aria/utils';
20
+ import {getInteractionModality} from './useFocusVisible';
21
+
22
+ /**
23
+ * A utility function that focuses an element while avoiding undesired side effects such
24
+ * as page scrolling and screen reader issues with CSS transitions.
25
+ */
26
+ export function focusSafely(element: FocusableElement) {
27
+ // If the user is interacting with a virtual cursor, e.g. screen reader, then
28
+ // wait until after any animated transitions that are currently occurring on
29
+ // the page before shifting focus. This avoids issues with VoiceOver on iOS
30
+ // causing the page to scroll when moving focus if the element is transitioning
31
+ // from off the screen.
32
+ const ownerDocument = getOwnerDocument(element);
33
+ const activeElement = getActiveElement(ownerDocument);
34
+ if (getInteractionModality() === 'virtual') {
35
+ let lastFocusedElement = activeElement;
36
+ runAfterTransition(() => {
37
+ // If focus did not move and the element is still in the document, focus it.
38
+ if (getActiveElement(ownerDocument) === lastFocusedElement && element.isConnected) {
39
+ focusWithoutScrolling(element);
40
+ }
41
+ });
42
+ } else {
43
+ focusWithoutScrolling(element);
44
+ }
45
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,8 @@ export {useMove} from './useMove';
30
30
  export {usePress} from './usePress';
31
31
  export {useScrollWheel} from './useScrollWheel';
32
32
  export {useLongPress} from './useLongPress';
33
+ export {useFocusable, FocusableProvider, Focusable, FocusableContext} from './useFocusable';
34
+ export {focusSafely} from './focusSafely';
33
35
 
34
36
  export type {FocusProps, FocusResult} from './useFocus';
35
37
  export type {FocusVisibleHandler, FocusVisibleProps, FocusVisibleResult, Modality} from './useFocusVisible';
@@ -42,3 +44,4 @@ export type {PressEvent, PressEvents, MoveStartEvent, MoveMoveEvent, MoveEndEven
42
44
  export type {MoveResult} from './useMove';
43
45
  export type {LongPressProps, LongPressResult} from './useLongPress';
44
46
  export type {ScrollWheelProps} from './useScrollWheel';
47
+ export type {FocusableAria, FocusableOptions, FocusableProviderProps} from './useFocusable';
@@ -46,8 +46,9 @@ export function disableTextSelection(target?: Element) {
46
46
  } else if (target instanceof HTMLElement || target instanceof SVGElement) {
47
47
  // If not iOS, store the target's original user-select and change to user-select: none
48
48
  // Ignore state since it doesn't apply for non iOS
49
- modifiedElementMap.set(target, target.style.userSelect);
50
- target.style.userSelect = 'none';
49
+ let property = 'userSelect' in target.style ? 'userSelect' : 'webkitUserSelect';
50
+ modifiedElementMap.set(target, target.style[property]);
51
+ target.style[property] = 'none';
51
52
  }
52
53
  }
53
54
 
@@ -85,9 +86,10 @@ export function restoreTextSelection(target?: Element) {
85
86
  // Ignore state since it doesn't apply for non iOS
86
87
  if (target && modifiedElementMap.has(target)) {
87
88
  let targetOldUserSelect = modifiedElementMap.get(target) as string;
89
+ let property = 'userSelect' in target.style ? 'userSelect' : 'webkitUserSelect';
88
90
 
89
- if (target.style.userSelect === 'none') {
90
- target.style.userSelect = targetOldUserSelect;
91
+ if (target.style[property] === 'none') {
92
+ target.style[property] = targetOldUserSelect;
91
93
  }
92
94
 
93
95
  if (target.getAttribute('style') === '') {
package/src/useFocus.ts CHANGED
@@ -17,7 +17,7 @@
17
17
 
18
18
  import {DOMAttributes, FocusableElement, FocusEvents} from '@react-types/shared';
19
19
  import {FocusEvent, useCallback} from 'react';
20
- import {getOwnerDocument} from '@react-aria/utils';
20
+ import {getActiveElement, getEventTarget, getOwnerDocument} from '@react-aria/utils';
21
21
  import {useSyntheticBlurEvent} from './utils';
22
22
 
23
23
  export interface FocusProps<Target = FocusableElement> extends FocusEvents<Target> {
@@ -64,8 +64,8 @@ export function useFocus<Target extends FocusableElement = FocusableElement>(pro
64
64
  // focus handler already moved focus somewhere else.
65
65
 
66
66
  const ownerDocument = getOwnerDocument(e.target);
67
-
68
- if (e.target === e.currentTarget && ownerDocument.activeElement === e.target) {
67
+ const activeElement = ownerDocument ? getActiveElement(ownerDocument) : getActiveElement();
68
+ if (e.target === e.currentTarget && activeElement === getEventTarget(e.nativeEvent)) {
69
69
  if (onFocusProp) {
70
70
  onFocusProp(e);
71
71
  }
@@ -16,6 +16,7 @@
16
16
  // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
17
17
 
18
18
  import {getOwnerDocument, getOwnerWindow, isMac, isVirtualClick} from '@react-aria/utils';
19
+ import {ignoreFocusEvent} from './utils';
19
20
  import {useEffect, useState} from 'react';
20
21
  import {useIsSSR} from '@react-aria/ssr';
21
22
 
@@ -92,7 +93,7 @@ function handleFocusEvent(e: FocusEvent) {
92
93
  // Firefox fires two extra focus events when the user first clicks into an iframe:
93
94
  // first on the window, then on the document. We ignore these events so they don't
94
95
  // cause keyboard focus rings to appear.
95
- if (e.target === window || e.target === document) {
96
+ if (e.target === window || e.target === document || ignoreFocusEvent || !e.isTrusted) {
96
97
  return;
97
98
  }
98
99
 
@@ -108,6 +109,10 @@ function handleFocusEvent(e: FocusEvent) {
108
109
  }
109
110
 
110
111
  function handleWindowBlur() {
112
+ if (ignoreFocusEvent) {
113
+ return;
114
+ }
115
+
111
116
  // When the window is blurred, reset state. This is necessary when tabbing out of the window,
112
117
  // for example, since a subsequent focus event won't be fired.
113
118
  hasEventBeforeFocus = false;
@@ -176,6 +181,7 @@ const tearDownWindowFocusTracking = (element, loadListener?: () => void) => {
176
181
  documentObject.removeEventListener('keydown', handleKeyboardEvent, true);
177
182
  documentObject.removeEventListener('keyup', handleKeyboardEvent, true);
178
183
  documentObject.removeEventListener('click', handleClickEvent, true);
184
+
179
185
  windowObject.removeEventListener('focus', handleFocusEvent, true);
180
186
  windowObject.removeEventListener('blur', handleWindowBlur, false);
181
187
 
@@ -284,15 +290,18 @@ const nonTextInputTypes = new Set([
284
290
  * focus visible style can be properly set.
285
291
  */
286
292
  function isKeyboardFocusEvent(isTextInput: boolean, modality: Modality, e: HandlerEvent) {
293
+ let document = getOwnerDocument(e?.target as Element);
287
294
  const IHTMLInputElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLInputElement : HTMLInputElement;
288
295
  const IHTMLTextAreaElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLTextAreaElement : HTMLTextAreaElement;
289
296
  const IHTMLElement = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).HTMLElement : HTMLElement;
290
297
  const IKeyboardEvent = typeof window !== 'undefined' ? getOwnerWindow(e?.target as Element).KeyboardEvent : KeyboardEvent;
291
298
 
299
+ // For keyboard events that occur on a non-input element that will move focus into input element (aka ArrowLeft going from Datepicker button to the main input group)
300
+ // we need to rely on the user passing isTextInput into here. This way we can skip toggling focus visiblity for said input element
292
301
  isTextInput = isTextInput ||
293
- (e?.target instanceof IHTMLInputElement && !nonTextInputTypes.has(e?.target?.type)) ||
294
- e?.target instanceof IHTMLTextAreaElement ||
295
- (e?.target instanceof IHTMLElement && e?.target.isContentEditable);
302
+ (document.activeElement instanceof IHTMLInputElement && !nonTextInputTypes.has(document.activeElement.type)) ||
303
+ document.activeElement instanceof IHTMLTextAreaElement ||
304
+ (document.activeElement instanceof IHTMLElement && document.activeElement.isContentEditable);
296
305
  return !(isTextInput && modality === 'keyboard' && e instanceof IKeyboardEvent && !FOCUS_VISIBLE_INPUT_KEYS[e.key]);
297
306
  }
298
307
 
@@ -317,6 +326,7 @@ export function useFocusVisibleListener(fn: FocusVisibleHandler, deps: ReadonlyA
317
326
 
318
327
  useEffect(() => {
319
328
  let handler = (modality: Modality, e: HandlerEvent) => {
329
+ // We want to early return for any keyboard events that occur inside text inputs EXCEPT for Tab and Escape
320
330
  if (!isKeyboardFocusEvent(!!(opts?.isTextInput), modality, e)) {
321
331
  return;
322
332
  }
@@ -17,7 +17,8 @@
17
17
 
18
18
  import {DOMAttributes} from '@react-types/shared';
19
19
  import {FocusEvent, useCallback, useRef} from 'react';
20
- import {useSyntheticBlurEvent} from './utils';
20
+ import {getActiveElement, getEventTarget, getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils';
21
+ import {SyntheticFocusEvent, useSyntheticBlurEvent} from './utils';
21
22
 
22
23
  export interface FocusWithinProps {
23
24
  /** Whether the focus within events should be disabled. */
@@ -49,12 +50,20 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
49
50
  isFocusWithin: false
50
51
  });
51
52
 
53
+ let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
54
+
52
55
  let onBlur = useCallback((e: FocusEvent) => {
56
+ // Ignore events bubbling through portals.
57
+ if (!e.currentTarget.contains(e.target)) {
58
+ return;
59
+ }
60
+
53
61
  // We don't want to trigger onBlurWithin and then immediately onFocusWithin again
54
62
  // when moving focus inside the element. Only trigger if the currentTarget doesn't
55
63
  // include the relatedTarget (where focus is moving).
56
64
  if (state.current.isFocusWithin && !(e.currentTarget as Element).contains(e.relatedTarget as Element)) {
57
65
  state.current.isFocusWithin = false;
66
+ removeAllGlobalListeners();
58
67
 
59
68
  if (onBlurWithin) {
60
69
  onBlurWithin(e);
@@ -64,13 +73,20 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
64
73
  onFocusWithinChange(false);
65
74
  }
66
75
  }
67
- }, [onBlurWithin, onFocusWithinChange, state]);
76
+ }, [onBlurWithin, onFocusWithinChange, state, removeAllGlobalListeners]);
68
77
 
69
78
  let onSyntheticFocus = useSyntheticBlurEvent(onBlur);
70
79
  let onFocus = useCallback((e: FocusEvent) => {
80
+ // Ignore events bubbling through portals.
81
+ if (!e.currentTarget.contains(e.target)) {
82
+ return;
83
+ }
84
+
71
85
  // Double check that document.activeElement actually matches e.target in case a previously chained
72
86
  // focus handler already moved focus somewhere else.
73
- if (!state.current.isFocusWithin && document.activeElement === e.target) {
87
+ const ownerDocument = getOwnerDocument(e.target);
88
+ const activeElement = getActiveElement(ownerDocument);
89
+ if (!state.current.isFocusWithin && activeElement === getEventTarget(e.nativeEvent)) {
74
90
  if (onFocusWithin) {
75
91
  onFocusWithin(e);
76
92
  }
@@ -81,13 +97,26 @@ export function useFocusWithin(props: FocusWithinProps): FocusWithinResult {
81
97
 
82
98
  state.current.isFocusWithin = true;
83
99
  onSyntheticFocus(e);
100
+
101
+ // Browsers don't fire blur events when elements are removed from the DOM.
102
+ // However, if a focus event occurs outside the element we're tracking, we
103
+ // can manually fire onBlur.
104
+ let currentTarget = e.currentTarget;
105
+ addGlobalListener(ownerDocument, 'focus', e => {
106
+ if (state.current.isFocusWithin && !nodeContains(currentTarget, e.target as Element)) {
107
+ let event = new SyntheticFocusEvent('blur', new ownerDocument.defaultView!.FocusEvent('blur', {relatedTarget: e.target}));
108
+ event.target = currentTarget;
109
+ event.currentTarget = currentTarget;
110
+ onBlur(event);
111
+ }
112
+ }, {capture: true});
84
113
  }
85
- }, [onFocusWithin, onFocusWithinChange, onSyntheticFocus]);
114
+ }, [onFocusWithin, onFocusWithinChange, onSyntheticFocus, addGlobalListener, onBlur]);
86
115
 
87
116
  if (isDisabled) {
88
117
  return {
89
118
  focusWithinProps: {
90
- // These should not have been null, that would conflict in mergeProps
119
+ // These cannot be null, that would conflict in mergeProps
91
120
  onFocus: undefined,
92
121
  onBlur: undefined
93
122
  }
@@ -0,0 +1,183 @@
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 {DOMAttributes, FocusableDOMProps, FocusableElement, FocusableProps, RefObject} from '@react-types/shared';
14
+ import {focusSafely} from './';
15
+ import {getOwnerWindow, isFocusable, mergeProps, mergeRefs, useObjectRef, useSyncRef} from '@react-aria/utils';
16
+ import React, {ForwardedRef, forwardRef, MutableRefObject, ReactElement, ReactNode, useContext, useEffect, useRef} from 'react';
17
+ import {useFocus} from './useFocus';
18
+ import {useKeyboard} from './useKeyboard';
19
+
20
+ export interface FocusableOptions<T = FocusableElement> extends FocusableProps<T>, FocusableDOMProps {
21
+ /** Whether focus should be disabled. */
22
+ isDisabled?: boolean
23
+ }
24
+
25
+ export interface FocusableProviderProps extends DOMAttributes {
26
+ /** The child element to provide DOM props to. */
27
+ children?: ReactNode
28
+ }
29
+
30
+ interface FocusableContextValue extends FocusableProviderProps {
31
+ ref?: MutableRefObject<FocusableElement | null>
32
+ }
33
+
34
+ // Exported for @react-aria/collections, which forwards this context.
35
+ /** @private */
36
+ export let FocusableContext = React.createContext<FocusableContextValue | null>(null);
37
+
38
+ function useFocusableContext(ref: RefObject<FocusableElement | null>): FocusableContextValue {
39
+ let context = useContext(FocusableContext) || {};
40
+ useSyncRef(context, ref);
41
+
42
+ // eslint-disable-next-line
43
+ let {ref: _, ...otherProps} = context;
44
+ return otherProps;
45
+ }
46
+
47
+ /**
48
+ * Provides DOM props to the nearest focusable child.
49
+ */
50
+ export const FocusableProvider = React.forwardRef(function FocusableProvider(props: FocusableProviderProps, ref: ForwardedRef<FocusableElement>) {
51
+ let {children, ...otherProps} = props;
52
+ let objRef = useObjectRef(ref);
53
+ let context = {
54
+ ...otherProps,
55
+ ref: objRef
56
+ };
57
+
58
+ return (
59
+ <FocusableContext.Provider value={context}>
60
+ {children}
61
+ </FocusableContext.Provider>
62
+ );
63
+ });
64
+
65
+ export interface FocusableAria {
66
+ /** Props for the focusable element. */
67
+ focusableProps: DOMAttributes
68
+ }
69
+
70
+ /**
71
+ * Used to make an element focusable and capable of auto focus.
72
+ */
73
+ export function useFocusable<T extends FocusableElement = FocusableElement>(props: FocusableOptions<T>, domRef: RefObject<FocusableElement | null>): FocusableAria {
74
+ let {focusProps} = useFocus(props);
75
+ let {keyboardProps} = useKeyboard(props);
76
+ let interactions = mergeProps(focusProps, keyboardProps);
77
+ let domProps = useFocusableContext(domRef);
78
+ let interactionProps = props.isDisabled ? {} : domProps;
79
+ let autoFocusRef = useRef(props.autoFocus);
80
+
81
+ useEffect(() => {
82
+ if (autoFocusRef.current && domRef.current) {
83
+ focusSafely(domRef.current);
84
+ }
85
+ autoFocusRef.current = false;
86
+ }, [domRef]);
87
+
88
+ // Always set a tabIndex so that Safari allows focusing native buttons and inputs.
89
+ let tabIndex: number | undefined = props.excludeFromTabOrder ? -1 : 0;
90
+ if (props.isDisabled) {
91
+ tabIndex = undefined;
92
+ }
93
+
94
+ return {
95
+ focusableProps: mergeProps(
96
+ {
97
+ ...interactions,
98
+ tabIndex
99
+ },
100
+ interactionProps
101
+ )
102
+ };
103
+ }
104
+
105
+ export interface FocusableComponentProps extends FocusableOptions {
106
+ children: ReactElement<DOMAttributes, string>
107
+ }
108
+
109
+ export const Focusable = forwardRef(({children, ...props}: FocusableComponentProps, ref: ForwardedRef<FocusableElement>) => {
110
+ ref = useObjectRef(ref);
111
+ let {focusableProps} = useFocusable(props, ref);
112
+ let child = React.Children.only(children);
113
+
114
+ useEffect(() => {
115
+ let el = ref.current;
116
+ if (!el || !(el instanceof getOwnerWindow(el).Element)) {
117
+ console.error('<Focusable> child must forward its ref to a DOM element.');
118
+ return;
119
+ }
120
+
121
+ if (!props.isDisabled && !isFocusable(el)) {
122
+ console.warn('<Focusable> child must be focusable. Please ensure the tabIndex prop is passed through.');
123
+ return;
124
+ }
125
+
126
+ if (
127
+ el.localName !== 'button' &&
128
+ el.localName !== 'input' &&
129
+ el.localName !== 'select' &&
130
+ el.localName !== 'textarea' &&
131
+ el.localName !== 'a' &&
132
+ el.localName !== 'area' &&
133
+ el.localName !== 'summary' &&
134
+ el.localName !== 'img' &&
135
+ el.localName !== 'svg'
136
+ ) {
137
+ let role = el.getAttribute('role');
138
+ if (!role) {
139
+ console.warn('<Focusable> child must have an interactive ARIA role.');
140
+ } else if (
141
+ // https://w3c.github.io/aria/#widget_roles
142
+ role !== 'application' &&
143
+ role !== 'button' &&
144
+ role !== 'checkbox' &&
145
+ role !== 'combobox' &&
146
+ role !== 'gridcell' &&
147
+ role !== 'link' &&
148
+ role !== 'menuitem' &&
149
+ role !== 'menuitemcheckbox' &&
150
+ role !== 'menuitemradio' &&
151
+ role !== 'option' &&
152
+ role !== 'radio' &&
153
+ role !== 'searchbox' &&
154
+ role !== 'separator' &&
155
+ role !== 'slider' &&
156
+ role !== 'spinbutton' &&
157
+ role !== 'switch' &&
158
+ role !== 'tab' &&
159
+ role !== 'tabpanel' &&
160
+ role !== 'textbox' &&
161
+ role !== 'treeitem' &&
162
+ // aria-describedby is also announced on these roles
163
+ role !== 'img' &&
164
+ role !== 'meter' &&
165
+ role !== 'progressbar'
166
+ ) {
167
+ console.warn(`<Focusable> child must have an interactive ARIA role. Got "${role}".`);
168
+ }
169
+ }
170
+ }, [ref, props.isDisabled]);
171
+
172
+ // @ts-ignore
173
+ let childRef = parseInt(React.version, 10) < 19 ? child.ref : child.props.ref;
174
+
175
+ return React.cloneElement(
176
+ child,
177
+ {
178
+ ...mergeProps(focusableProps, child.props),
179
+ // @ts-ignore
180
+ ref: mergeRefs(childRef, ref)
181
+ }
182
+ );
183
+ });
package/src/useHover.ts CHANGED
@@ -16,6 +16,7 @@
16
16
  // See https://github.com/facebook/react/tree/cc7c1aece46a6b69b41958d731e0fd27c94bfc6c/packages/react-interactions
17
17
 
18
18
  import {DOMAttributes, HoverEvents} from '@react-types/shared';
19
+ import {getOwnerDocument, nodeContains, useGlobalListeners} from '@react-aria/utils';
19
20
  import {useEffect, useMemo, useRef, useState} from 'react';
20
21
 
21
22
  export interface HoverProps extends HoverEvents {
@@ -100,6 +101,7 @@ export function useHover(props: HoverProps): HoverResult {
100
101
  }).current;
101
102
 
102
103
  useEffect(setupGlobalTouchEvents, []);
104
+ let {addGlobalListener, removeAllGlobalListeners} = useGlobalListeners();
103
105
 
104
106
  let {hoverProps, triggerHoverEnd} = useMemo(() => {
105
107
  let triggerHoverStart = (event, pointerType) => {
@@ -112,6 +114,16 @@ export function useHover(props: HoverProps): HoverResult {
112
114
  let target = event.currentTarget;
113
115
  state.target = target;
114
116
 
117
+ // When an element that is hovered over is removed, no pointerleave event is fired by the browser,
118
+ // even though the originally hovered target may have shrunk in size so it is no longer hovered.
119
+ // However, a pointerover event will be fired on the new target the mouse is over.
120
+ // In Chrome this happens immediately. In Safari and Firefox, it happens upon moving the mouse one pixel.
121
+ addGlobalListener(getOwnerDocument(event.target), 'pointerover', e => {
122
+ if (state.isHovered && state.target && !nodeContains(state.target, e.target as Element)) {
123
+ triggerHoverEnd(e, e.pointerType);
124
+ }
125
+ }, {capture: true});
126
+
115
127
  if (onHoverStart) {
116
128
  onHoverStart({
117
129
  type: 'hoverstart',
@@ -128,15 +140,17 @@ export function useHover(props: HoverProps): HoverResult {
128
140
  };
129
141
 
130
142
  let triggerHoverEnd = (event, pointerType) => {
143
+ let target = state.target;
131
144
  state.pointerType = '';
132
145
  state.target = null;
133
146
 
134
- if (pointerType === 'touch' || !state.isHovered) {
147
+ if (pointerType === 'touch' || !state.isHovered || !target) {
135
148
  return;
136
149
  }
137
150
 
138
151
  state.isHovered = false;
139
- let target = event.currentTarget;
152
+ removeAllGlobalListeners();
153
+
140
154
  if (onHoverEnd) {
141
155
  onHoverEnd({
142
156
  type: 'hoverend',
@@ -188,7 +202,7 @@ export function useHover(props: HoverProps): HoverResult {
188
202
  };
189
203
  }
190
204
  return {hoverProps, triggerHoverEnd};
191
- }, [onHoverStart, onHoverChange, onHoverEnd, isDisabled, state]);
205
+ }, [onHoverStart, onHoverChange, onHoverEnd, isDisabled, state, addGlobalListener, removeAllGlobalListeners]);
192
206
 
193
207
  useEffect(() => {
194
208
  // Call the triggerHoverEnd as soon as isDisabled changes to true
@@ -116,19 +116,25 @@ function isValidEvent(event, ref) {
116
116
  if (event.button > 0) {
117
117
  return false;
118
118
  }
119
-
120
119
  if (event.target) {
121
120
  // if the event target is no longer in the document, ignore
122
121
  const ownerDocument = event.target.ownerDocument;
123
122
  if (!ownerDocument || !ownerDocument.documentElement.contains(event.target)) {
124
123
  return false;
125
124
  }
126
-
127
125
  // If the target is within a top layer element (e.g. toasts), ignore.
128
126
  if (event.target.closest('[data-react-aria-top-layer]')) {
129
127
  return false;
130
128
  }
131
129
  }
132
130
 
133
- return ref.current && !ref.current.contains(event.target);
131
+ if (!ref.current) {
132
+ return false;
133
+ }
134
+
135
+ // When the event source is inside a Shadow DOM, event.target is just the shadow root.
136
+ // Using event.composedPath instead means we can get the actual element inside the shadow root.
137
+ // This only works if the shadow root is open, there is no way to detect if it is closed.
138
+ // If the event composed path contains the ref, interaction is inside.
139
+ return !event.composedPath().includes(ref.current);
134
140
  }
@@ -10,8 +10,8 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
 
13
- import {DOMAttributes, LongPressEvent} from '@react-types/shared';
14
- import {mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils';
13
+ import {DOMAttributes, FocusableElement, LongPressEvent} from '@react-types/shared';
14
+ import {focusWithoutScrolling, getOwnerDocument, mergeProps, useDescription, useGlobalListeners} from '@react-aria/utils';
15
15
  import {usePress} from './usePress';
16
16
  import {useRef} from 'react';
17
17
 
@@ -81,6 +81,12 @@ export function useLongPress(props: LongPressProps): LongPressResult {
81
81
  timeRef.current = setTimeout(() => {
82
82
  // Prevent other usePress handlers from also handling this event.
83
83
  e.target.dispatchEvent(new PointerEvent('pointercancel', {bubbles: true}));
84
+
85
+ // Ensure target is focused. On touch devices, browsers typically focus on pointer up.
86
+ if (getOwnerDocument(e.target).activeElement !== e.target) {
87
+ focusWithoutScrolling(e.target as FocusableElement);
88
+ }
89
+
84
90
  if (onLongPress) {
85
91
  onLongPress({
86
92
  ...e,