@react-aria/interactions 3.23.0 → 3.24.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/dist/Pressable.main.js +28 -4
- package/dist/Pressable.main.js.map +1 -1
- package/dist/Pressable.mjs +30 -6
- package/dist/Pressable.module.js +30 -6
- package/dist/Pressable.module.js.map +1 -1
- package/dist/focusSafely.main.js +40 -0
- package/dist/focusSafely.main.js.map +1 -0
- package/dist/focusSafely.mjs +35 -0
- package/dist/focusSafely.module.js +35 -0
- package/dist/focusSafely.module.js.map +1 -0
- package/dist/import.mjs +5 -1
- package/dist/main.js +9 -0
- package/dist/main.js.map +1 -1
- package/dist/module.js +5 -1
- package/dist/module.js.map +1 -1
- package/dist/textSelection.main.js +5 -3
- package/dist/textSelection.main.js.map +1 -1
- package/dist/textSelection.mjs +5 -3
- package/dist/textSelection.module.js +5 -3
- package/dist/textSelection.module.js.map +1 -1
- package/dist/types.d.ts +59 -25
- package/dist/types.d.ts.map +1 -1
- package/dist/useFocus.main.js +2 -1
- package/dist/useFocus.main.js.map +1 -1
- package/dist/useFocus.mjs +3 -2
- package/dist/useFocus.module.js +3 -2
- package/dist/useFocus.module.js.map +1 -1
- package/dist/useFocusVisible.main.js +9 -3
- package/dist/useFocusVisible.main.js.map +1 -1
- package/dist/useFocusVisible.mjs +9 -3
- package/dist/useFocusVisible.module.js +9 -3
- package/dist/useFocusVisible.module.js.map +1 -1
- package/dist/useFocusWithin.main.js +33 -4
- package/dist/useFocusWithin.main.js.map +1 -1
- package/dist/useFocusWithin.mjs +34 -5
- package/dist/useFocusWithin.module.js +34 -5
- package/dist/useFocusWithin.module.js.map +1 -1
- package/dist/useFocusable.main.js +112 -0
- package/dist/useFocusable.main.js.map +1 -0
- package/dist/useFocusable.mjs +100 -0
- package/dist/useFocusable.module.js +100 -0
- package/dist/useFocusable.module.js.map +1 -0
- package/dist/useHover.main.js +18 -3
- package/dist/useHover.main.js.map +1 -1
- package/dist/useHover.mjs +18 -3
- package/dist/useHover.module.js +18 -3
- package/dist/useHover.module.js.map +1 -1
- package/dist/useInteractOutside.main.js +6 -1
- package/dist/useInteractOutside.main.js.map +1 -1
- package/dist/useInteractOutside.mjs +6 -1
- package/dist/useInteractOutside.module.js +6 -1
- package/dist/useInteractOutside.module.js.map +1 -1
- package/dist/useLongPress.main.js +2 -0
- package/dist/useLongPress.main.js.map +1 -1
- package/dist/useLongPress.mjs +3 -1
- package/dist/useLongPress.module.js +3 -1
- package/dist/useLongPress.module.js.map +1 -1
- package/dist/usePress.main.js +85 -80
- package/dist/usePress.main.js.map +1 -1
- package/dist/usePress.mjs +86 -81
- package/dist/usePress.module.js +86 -81
- package/dist/usePress.module.js.map +1 -1
- package/dist/utils.main.js +57 -1
- package/dist/utils.main.js.map +1 -1
- package/dist/utils.mjs +55 -2
- package/dist/utils.module.js +55 -2
- package/dist/utils.module.js.map +1 -1
- package/package.json +5 -4
- package/src/Pressable.tsx +66 -6
- package/src/focusSafely.ts +45 -0
- package/src/index.ts +3 -0
- package/src/textSelection.ts +6 -4
- package/src/useFocus.ts +3 -3
- package/src/useFocusVisible.ts +14 -4
- package/src/useFocusWithin.ts +34 -5
- package/src/useFocusable.tsx +183 -0
- package/src/useHover.ts +17 -3
- package/src/useInteractOutside.ts +9 -3
- package/src/useLongPress.ts +8 -2
- package/src/usePress.ts +117 -115
- package/src/utils.ts +80 -1
- package/src/DOMPropsContext.ts +0 -39
- package/src/DOMPropsResponder.tsx +0 -47
- 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<
|
|
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
|
-
|
|
29
|
-
|
|
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';
|
package/src/textSelection.ts
CHANGED
|
@@ -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
|
-
|
|
50
|
-
target.style
|
|
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
|
|
90
|
-
target.style
|
|
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 &&
|
|
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
|
}
|
package/src/useFocusVisible.ts
CHANGED
|
@@ -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
|
-
(
|
|
294
|
-
|
|
295
|
-
(
|
|
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
|
}
|
package/src/useFocusWithin.ts
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
|
|
18
18
|
import {DOMAttributes} from '@react-types/shared';
|
|
19
19
|
import {FocusEvent, useCallback, useRef} from 'react';
|
|
20
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/useLongPress.ts
CHANGED
|
@@ -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,
|