@react-aria/menu 3.11.3-nightly.4318 → 3.11.3-nightly.4324
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/import.mjs +321 -72
- package/dist/main.js +318 -68
- package/dist/main.js.map +1 -1
- package/dist/module.js +321 -72
- package/dist/module.js.map +1 -1
- package/dist/types.d.ts +44 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +14 -14
- package/src/index.ts +2 -0
- package/src/useMenu.ts +5 -5
- package/src/useMenuItem.ts +61 -82
- package/src/useSafelyMouseToSubmenu.ts +158 -0
- package/src/useSubmenuTrigger.ts +235 -0
package/src/useMenuItem.ts
CHANGED
|
@@ -10,15 +10,13 @@
|
|
|
10
10
|
* governing permissions and limitations under the License.
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {DOMAttributes, FocusableElement, Key, PressEvent} from '@react-types/shared';
|
|
14
|
-
import {filterDOMProps, mergeProps,
|
|
15
|
-
import {focusSafely} from '@react-aria/focus';
|
|
13
|
+
import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents} from '@react-types/shared';
|
|
14
|
+
import {filterDOMProps, mergeProps, useRouter, useSlotId} from '@react-aria/utils';
|
|
16
15
|
import {getItemCount} from '@react-stately/collections';
|
|
17
|
-
import {isFocusVisible, useHover, useKeyboard, usePress} from '@react-aria/interactions';
|
|
16
|
+
import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions';
|
|
18
17
|
import {menuData} from './useMenu';
|
|
19
|
-
import {RefObject
|
|
18
|
+
import {RefObject} from 'react';
|
|
20
19
|
import {TreeState} from '@react-stately/tree';
|
|
21
|
-
import {useLocale} from '@react-aria/i18n';
|
|
22
20
|
import {useSelectableItem} from '@react-aria/selection';
|
|
23
21
|
|
|
24
22
|
export interface MenuItemAria {
|
|
@@ -44,7 +42,7 @@ export interface MenuItemAria {
|
|
|
44
42
|
isDisabled: boolean
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
export interface AriaMenuItemProps {
|
|
45
|
+
export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, KeyboardEvents, FocusEvents {
|
|
48
46
|
/**
|
|
49
47
|
* Whether the menu item is disabled.
|
|
50
48
|
* @deprecated - pass disabledKeys to useTreeState instead.
|
|
@@ -85,7 +83,13 @@ export interface AriaMenuItemProps {
|
|
|
85
83
|
onAction?: (key: Key) => void,
|
|
86
84
|
|
|
87
85
|
/** What kind of popup the item opens. */
|
|
88
|
-
'aria-haspopup'?: 'menu' | 'dialog'
|
|
86
|
+
'aria-haspopup'?: 'menu' | 'dialog',
|
|
87
|
+
|
|
88
|
+
/** Indicates whether the menu item's popup element is expanded or collapsed. */
|
|
89
|
+
'aria-expanded'?: boolean | 'true' | 'false',
|
|
90
|
+
|
|
91
|
+
/** Identifies the menu item's popup element whose contents or presence is controlled by the menu item. */
|
|
92
|
+
'aria-controls'?: string
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/**
|
|
@@ -99,41 +103,28 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
99
103
|
key,
|
|
100
104
|
closeOnSelect,
|
|
101
105
|
isVirtualized,
|
|
102
|
-
'aria-haspopup': hasPopup
|
|
106
|
+
'aria-haspopup': hasPopup,
|
|
107
|
+
onPressStart: pressStartProp,
|
|
108
|
+
onPressUp: pressUpProp,
|
|
109
|
+
onPress,
|
|
110
|
+
onPressChange,
|
|
111
|
+
onPressEnd,
|
|
112
|
+
onHoverStart: hoverStartProp,
|
|
113
|
+
onHoverChange,
|
|
114
|
+
onHoverEnd,
|
|
115
|
+
onKeyDown,
|
|
116
|
+
onKeyUp,
|
|
117
|
+
onFocus,
|
|
118
|
+
onFocusChange,
|
|
119
|
+
onBlur
|
|
103
120
|
} = props;
|
|
104
|
-
let {direction} = useLocale();
|
|
105
121
|
|
|
106
122
|
let isTrigger = !!hasPopup;
|
|
107
|
-
let isOpen = state.expandedKeys.has(key);
|
|
108
|
-
|
|
109
123
|
let isDisabled = props.isDisabled ?? state.disabledKeys.has(key);
|
|
110
124
|
let isSelected = props.isSelected ?? state.selectionManager.isSelected(key);
|
|
111
|
-
|
|
112
|
-
let openTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
|
|
113
|
-
let cancelOpenTimeout = useCallback(() => {
|
|
114
|
-
if (openTimeout.current) {
|
|
115
|
-
clearTimeout(openTimeout.current);
|
|
116
|
-
openTimeout.current = undefined;
|
|
117
|
-
}
|
|
118
|
-
}, [openTimeout]);
|
|
119
|
-
|
|
120
|
-
let onSubmenuOpen = useEffectEvent(() => {
|
|
121
|
-
cancelOpenTimeout();
|
|
122
|
-
state.setExpandedKeys(new Set([key]));
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
useLayoutEffect(() => {
|
|
126
|
-
return () => cancelOpenTimeout();
|
|
127
|
-
}, [cancelOpenTimeout]);
|
|
128
|
-
|
|
129
125
|
let data = menuData.get(state);
|
|
130
126
|
let onClose = props.onClose || data.onClose;
|
|
131
|
-
let
|
|
132
|
-
onSubmenuOpen();
|
|
133
|
-
// will need to disable this lint rule when using useEffectEvent https://react.dev/learn/separating-events-from-effects#logic-inside-effects-is-reactive
|
|
134
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
135
|
-
}, []);
|
|
136
|
-
let onAction = isTrigger ? onActionMenuDialogTrigger : props.onAction || data.onAction;
|
|
127
|
+
let onAction = isTrigger ? () => {} : props.onAction || data.onAction;
|
|
137
128
|
let router = useRouter();
|
|
138
129
|
let performAction = (e: PressEvent) => {
|
|
139
130
|
if (onAction) {
|
|
@@ -146,10 +137,12 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
146
137
|
};
|
|
147
138
|
|
|
148
139
|
let role = 'menuitem';
|
|
149
|
-
if (
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
140
|
+
if (!isTrigger) {
|
|
141
|
+
if (state.selectionManager.selectionMode === 'single') {
|
|
142
|
+
role = 'menuitemradio';
|
|
143
|
+
} else if (state.selectionManager.selectionMode === 'multiple') {
|
|
144
|
+
role = 'menuitemcheckbox';
|
|
145
|
+
}
|
|
153
146
|
}
|
|
154
147
|
|
|
155
148
|
let labelId = useSlotId();
|
|
@@ -161,10 +154,13 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
161
154
|
role,
|
|
162
155
|
'aria-label': props['aria-label'],
|
|
163
156
|
'aria-labelledby': labelId,
|
|
164
|
-
'aria-describedby': [descriptionId, keyboardId].filter(Boolean).join(' ') || undefined
|
|
157
|
+
'aria-describedby': [descriptionId, keyboardId].filter(Boolean).join(' ') || undefined,
|
|
158
|
+
'aria-controls': props['aria-controls'],
|
|
159
|
+
'aria-haspopup': hasPopup,
|
|
160
|
+
'aria-expanded': props['aria-expanded']
|
|
165
161
|
};
|
|
166
162
|
|
|
167
|
-
if (state.selectionManager.selectionMode !== 'none') {
|
|
163
|
+
if (state.selectionManager.selectionMode !== 'none' && !isTrigger) {
|
|
168
164
|
ariaProps['aria-checked'] = isSelected;
|
|
169
165
|
}
|
|
170
166
|
|
|
@@ -174,15 +170,12 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
174
170
|
ariaProps['aria-setsize'] = getItemCount(state.collection);
|
|
175
171
|
}
|
|
176
172
|
|
|
177
|
-
if (hasPopup != null) {
|
|
178
|
-
ariaProps['aria-haspopup'] = hasPopup;
|
|
179
|
-
ariaProps['aria-expanded'] = isOpen ? 'true' : 'false';
|
|
180
|
-
}
|
|
181
|
-
|
|
182
173
|
let onPressStart = (e: PressEvent) => {
|
|
183
174
|
if (e.pointerType === 'keyboard') {
|
|
184
175
|
performAction(e);
|
|
185
176
|
}
|
|
177
|
+
|
|
178
|
+
pressStartProp?.(e);
|
|
186
179
|
};
|
|
187
180
|
|
|
188
181
|
let onPressUp = (e: PressEvent) => {
|
|
@@ -195,6 +188,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
195
188
|
onClose();
|
|
196
189
|
}
|
|
197
190
|
}
|
|
191
|
+
|
|
192
|
+
pressUpProp?.(e);
|
|
198
193
|
};
|
|
199
194
|
|
|
200
195
|
let {itemProps, isFocused} = useSelectableItem({
|
|
@@ -212,33 +207,23 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
212
207
|
|
|
213
208
|
let {pressProps, isPressed} = usePress({
|
|
214
209
|
onPressStart,
|
|
210
|
+
onPress,
|
|
215
211
|
onPressUp,
|
|
216
|
-
|
|
212
|
+
onPressChange,
|
|
213
|
+
onPressEnd,
|
|
214
|
+
isDisabled
|
|
217
215
|
});
|
|
218
216
|
let {hoverProps} = useHover({
|
|
219
217
|
isDisabled,
|
|
220
|
-
onHoverStart() {
|
|
221
|
-
if (!isFocusVisible()
|
|
218
|
+
onHoverStart(e) {
|
|
219
|
+
if (!isFocusVisible()) {
|
|
222
220
|
state.selectionManager.setFocused(true);
|
|
223
221
|
state.selectionManager.setFocusedKey(key);
|
|
224
|
-
// focus immediately so that a focus scope opened on hover has the correct restore node
|
|
225
|
-
let isFocused = key === state.selectionManager.focusedKey;
|
|
226
|
-
if (isFocused && state.selectionManager.isFocused && document.activeElement !== ref.current) {
|
|
227
|
-
focusSafely(ref.current);
|
|
228
|
-
}
|
|
229
222
|
}
|
|
223
|
+
hoverStartProp?.(e);
|
|
230
224
|
},
|
|
231
|
-
onHoverChange
|
|
232
|
-
|
|
233
|
-
if (!openTimeout.current) {
|
|
234
|
-
openTimeout.current = setTimeout(() => {
|
|
235
|
-
onSubmenuOpen();
|
|
236
|
-
}, 200);
|
|
237
|
-
}
|
|
238
|
-
} else if (!isHovered) {
|
|
239
|
-
cancelOpenTimeout();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
225
|
+
onHoverChange,
|
|
226
|
+
onHoverEnd
|
|
242
227
|
});
|
|
243
228
|
|
|
244
229
|
let {keyboardProps} = useKeyboard({
|
|
@@ -262,33 +247,27 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
|
|
|
262
247
|
onClose();
|
|
263
248
|
}
|
|
264
249
|
break;
|
|
265
|
-
|
|
266
|
-
if (isTrigger
|
|
267
|
-
onSubmenuOpen();
|
|
268
|
-
} else {
|
|
269
|
-
e.continuePropagation();
|
|
270
|
-
}
|
|
271
|
-
break;
|
|
272
|
-
case 'ArrowLeft':
|
|
273
|
-
if (isTrigger && direction === 'rtl') {
|
|
274
|
-
onSubmenuOpen();
|
|
275
|
-
} else {
|
|
250
|
+
default:
|
|
251
|
+
if (!isTrigger) {
|
|
276
252
|
e.continuePropagation();
|
|
277
253
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
e.continuePropagation();
|
|
254
|
+
|
|
255
|
+
onKeyDown?.(e);
|
|
281
256
|
break;
|
|
282
257
|
}
|
|
283
|
-
}
|
|
258
|
+
},
|
|
259
|
+
onKeyUp
|
|
284
260
|
});
|
|
285
261
|
|
|
262
|
+
let {focusProps} = useFocus({onBlur, onFocus, onFocusChange});
|
|
286
263
|
let domProps = filterDOMProps(item.props, {isLink: !!item?.props?.href});
|
|
287
264
|
delete domProps.id;
|
|
265
|
+
|
|
288
266
|
return {
|
|
289
267
|
menuItemProps: {
|
|
290
268
|
...ariaProps,
|
|
291
|
-
...mergeProps(domProps, itemProps, pressProps, hoverProps, keyboardProps)
|
|
269
|
+
...mergeProps(domProps, isTrigger ? {onFocus: itemProps.onFocus} : itemProps, pressProps, hoverProps, keyboardProps, focusProps),
|
|
270
|
+
tabIndex: itemProps.tabIndex != null ? -1 : undefined
|
|
292
271
|
},
|
|
293
272
|
labelProps: {
|
|
294
273
|
id: labelId
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import {RefObject, useEffect, useRef, useState} from 'react';
|
|
2
|
+
import {useInteractionModality} from '@react-aria/interactions';
|
|
3
|
+
import {useResizeObserver} from '@react-aria/utils';
|
|
4
|
+
|
|
5
|
+
interface SafelyMouseToSubmenuOptions {
|
|
6
|
+
/** Ref for the parent menu. */
|
|
7
|
+
menuRef: RefObject<Element>,
|
|
8
|
+
/** Ref for the submenu. */
|
|
9
|
+
submenuRef: RefObject<Element>,
|
|
10
|
+
/** Whether the submenu is open. */
|
|
11
|
+
isOpen: boolean,
|
|
12
|
+
/** Whether this feature is disabled. */
|
|
13
|
+
isDisabled?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ALLOWED_INVALID_MOVEMENTS = 2;
|
|
17
|
+
const THROTTLE_TIME = 50;
|
|
18
|
+
const TIMEOUT_TIME = 1000;
|
|
19
|
+
const ANGLE_PADDING = Math.PI / 12; // 15°
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Allows the user to move their pointer to the submenu without it closing when their mouse leaves the trigger element.
|
|
23
|
+
* Prevents pointer events from going to the underlying menu if the user is moving their pointer towards the sub-menu.
|
|
24
|
+
*/
|
|
25
|
+
export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions) {
|
|
26
|
+
let {menuRef, submenuRef, isOpen, isDisabled} = options;
|
|
27
|
+
let prevPointerPos = useRef<{x: number, y: number} | undefined>();
|
|
28
|
+
let submenuRect = useRef<DOMRect | undefined>();
|
|
29
|
+
let lastProcessedTime = useRef<number>(0);
|
|
30
|
+
let timeout = useRef<ReturnType<typeof setTimeout> | undefined>();
|
|
31
|
+
let autoCloseTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
|
|
32
|
+
let submenuSide = useRef<'left' | 'right' | undefined>();
|
|
33
|
+
let movementsTowardsSubmenuCount = useRef<number>(2);
|
|
34
|
+
let [preventPointerEvents, setPreventPointerEvents] = useState(false);
|
|
35
|
+
|
|
36
|
+
let updateSubmenuRect = () => {
|
|
37
|
+
if (submenuRef.current) {
|
|
38
|
+
submenuRect.current = submenuRef.current.getBoundingClientRect();
|
|
39
|
+
submenuSide.current = undefined;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
useResizeObserver({ref: submenuRef, onResize: updateSubmenuRect});
|
|
43
|
+
|
|
44
|
+
let reset = () => {
|
|
45
|
+
setPreventPointerEvents(false);
|
|
46
|
+
movementsTowardsSubmenuCount.current = ALLOWED_INVALID_MOVEMENTS;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
let modality = useInteractionModality();
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (preventPointerEvents && menuRef.current) {
|
|
53
|
+
(menuRef.current as HTMLElement).style.pointerEvents = 'none';
|
|
54
|
+
} else {
|
|
55
|
+
(menuRef.current as HTMLElement).style.pointerEvents = '';
|
|
56
|
+
}
|
|
57
|
+
}, [menuRef, preventPointerEvents]);
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
let submenu = submenuRef.current;
|
|
61
|
+
let menu = menuRef.current;
|
|
62
|
+
|
|
63
|
+
if (isDisabled || !submenu || !isOpen || modality !== 'pointer') {
|
|
64
|
+
reset();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
submenuRect.current = submenu.getBoundingClientRect();
|
|
68
|
+
|
|
69
|
+
let onPointerMove = (e: PointerEvent) => {
|
|
70
|
+
if (e.pointerType === 'touch' || e.pointerType === 'pen') {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let currentTime = Date.now();
|
|
75
|
+
|
|
76
|
+
// Throttle
|
|
77
|
+
if (currentTime - lastProcessedTime.current < THROTTLE_TIME) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
clearTimeout(timeout.current);
|
|
81
|
+
clearTimeout(autoCloseTimeout.current);
|
|
82
|
+
|
|
83
|
+
let {clientX: mouseX, clientY: mouseY} = e;
|
|
84
|
+
|
|
85
|
+
if (!prevPointerPos.current) {
|
|
86
|
+
prevPointerPos.current = {x: mouseX, y: mouseY};
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!submenuRect.current) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!submenuSide.current) {
|
|
95
|
+
submenuSide.current = mouseX > submenuRect.current.right ? 'left' : 'right';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pointer is outside of parent menu
|
|
99
|
+
if (mouseX < menu.getBoundingClientRect().left || mouseX > menu.getBoundingClientRect().right || mouseY < menu.getBoundingClientRect().top || mouseY > menu.getBoundingClientRect().bottom) {
|
|
100
|
+
reset();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* Check if pointer is moving towards submenu.
|
|
105
|
+
Uses the 2-argument arctangent (https://en.wikipedia.org/wiki/Atan2) to calculate:
|
|
106
|
+
- angle between previous pointer and top of submenu
|
|
107
|
+
- angle between previous pointer and bottom of submenu
|
|
108
|
+
- angle between previous pointer and current pointer (delta)
|
|
109
|
+
If the pointer delta angle value is between the top and bottom angle values, we know the pointer is moving towards the submenu.
|
|
110
|
+
*/
|
|
111
|
+
let prevMouseX = prevPointerPos.current.x;
|
|
112
|
+
let prevMouseY = prevPointerPos.current.y;
|
|
113
|
+
let toSubmenuX = submenuSide.current === 'right' ? submenuRect.current.left - prevMouseX : prevMouseX - submenuRect.current.right;
|
|
114
|
+
let angleTop = Math.atan2(prevMouseY - submenuRect.current.top, toSubmenuX) + ANGLE_PADDING;
|
|
115
|
+
let angleBottom = Math.atan2(prevMouseY - submenuRect.current.bottom, toSubmenuX) - ANGLE_PADDING;
|
|
116
|
+
let anglePointer = Math.atan2(prevMouseY - mouseY, (submenuSide.current === 'left' ? -(mouseX - prevMouseX) : mouseX - prevMouseX));
|
|
117
|
+
let isMovingTowardsSubmenu = anglePointer < angleTop && anglePointer > angleBottom;
|
|
118
|
+
|
|
119
|
+
movementsTowardsSubmenuCount.current = isMovingTowardsSubmenu ?
|
|
120
|
+
Math.min(movementsTowardsSubmenuCount.current + 1, ALLOWED_INVALID_MOVEMENTS) :
|
|
121
|
+
Math.max(movementsTowardsSubmenuCount.current - 1, 0);
|
|
122
|
+
|
|
123
|
+
if (movementsTowardsSubmenuCount.current >= ALLOWED_INVALID_MOVEMENTS) {
|
|
124
|
+
setPreventPointerEvents(true);
|
|
125
|
+
} else {
|
|
126
|
+
setPreventPointerEvents(false);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
lastProcessedTime.current = currentTime;
|
|
130
|
+
prevPointerPos.current = {x: mouseX, y: mouseY};
|
|
131
|
+
|
|
132
|
+
// If the pointer is moving towards the submenu, start a timeout to close if no other movements are made after 500ms.
|
|
133
|
+
if (isMovingTowardsSubmenu) {
|
|
134
|
+
timeout.current = setTimeout(() => {
|
|
135
|
+
reset();
|
|
136
|
+
autoCloseTimeout.current = setTimeout(() => {
|
|
137
|
+
// Fire a pointerover event to trigger the menu to close.
|
|
138
|
+
// Wait until pointer-events:none is no longer applied
|
|
139
|
+
let target = document.elementFromPoint(mouseX, mouseY);
|
|
140
|
+
if (target && menu.contains(target)) {
|
|
141
|
+
target.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, cancelable: true}));
|
|
142
|
+
}
|
|
143
|
+
}, 100);
|
|
144
|
+
}, TIMEOUT_TIME);
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
window.addEventListener('pointermove', onPointerMove);
|
|
149
|
+
|
|
150
|
+
return () => {
|
|
151
|
+
window.removeEventListener('pointermove', onPointerMove);
|
|
152
|
+
clearTimeout(timeout.current);
|
|
153
|
+
clearTimeout(autoCloseTimeout.current);
|
|
154
|
+
movementsTowardsSubmenuCount.current = ALLOWED_INVALID_MOVEMENTS;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
}, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, submenuRef]);
|
|
158
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2023 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 {AriaMenuItemProps} from './useMenuItem';
|
|
14
|
+
import {AriaMenuOptions} from './useMenu';
|
|
15
|
+
import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays';
|
|
16
|
+
import {FocusableElement, FocusStrategy, KeyboardEvent, PressEvent, Node as RSNode} from '@react-types/shared';
|
|
17
|
+
import {RefObject, useCallback, useRef} from 'react';
|
|
18
|
+
import type {SubmenuTriggerState} from '@react-stately/menu';
|
|
19
|
+
import {useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils';
|
|
20
|
+
import {useLocale} from '@react-aria/i18n';
|
|
21
|
+
import {useSafelyMouseToSubmenu} from './useSafelyMouseToSubmenu';
|
|
22
|
+
|
|
23
|
+
export interface AriaSubmenuTriggerProps {
|
|
24
|
+
/** An object representing the submenu trigger menu item. Contains all the relevant information that makes up the menu item. */
|
|
25
|
+
node: RSNode<unknown>,
|
|
26
|
+
/** Whether the submenu trigger is disabled. */
|
|
27
|
+
isDisabled?: boolean,
|
|
28
|
+
/** The type of the contents that the submenu trigger opens. */
|
|
29
|
+
type?: 'dialog' | 'menu',
|
|
30
|
+
/** Ref of the menu that contains the submenu trigger. */
|
|
31
|
+
parentMenuRef: RefObject<HTMLElement>,
|
|
32
|
+
/** Ref of the submenu opened by the submenu trigger. */
|
|
33
|
+
submenuRef: RefObject<HTMLElement>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SubmenuTriggerProps extends AriaMenuItemProps {
|
|
37
|
+
/** Whether the submenu trigger is in an expanded state. */
|
|
38
|
+
isOpen: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface SubmenuProps<T> extends AriaMenuOptions<T> {
|
|
42
|
+
/** The level of the submenu. */
|
|
43
|
+
submenuLevel: number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SubmenuTriggerAria<T> {
|
|
47
|
+
/** Props for the submenu trigger menu item. */
|
|
48
|
+
submenuTriggerProps: SubmenuTriggerProps,
|
|
49
|
+
/** Props for the submenu controlled by the submenu trigger menu item. */
|
|
50
|
+
submenuProps: SubmenuProps<T>,
|
|
51
|
+
/** Props for the submenu's popover container. */
|
|
52
|
+
popoverProps: Pick<AriaPopoverProps, 'isNonModal' | 'shouldCloseOnInteractOutside'> & Pick<OverlayProps, 'disableFocusManagement'>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Provides the behavior and accessibility implementation for a submenu trigger and its associated submenu.
|
|
57
|
+
* @param props - Props for the submenu trigger and refs attach to its submenu and parent menu.
|
|
58
|
+
* @param state - State for the submenu trigger.
|
|
59
|
+
* @param ref - Ref to the submenu trigger element.
|
|
60
|
+
*/
|
|
61
|
+
export function UNSTABLE_useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: SubmenuTriggerState, ref: RefObject<FocusableElement>): SubmenuTriggerAria<T> {
|
|
62
|
+
let {parentMenuRef, submenuRef, type = 'menu', isDisabled, node} = props;
|
|
63
|
+
let submenuTriggerId = useId();
|
|
64
|
+
let overlayId = useId();
|
|
65
|
+
let {direction} = useLocale();
|
|
66
|
+
let openTimeout = useRef<ReturnType<typeof setTimeout> | undefined>();
|
|
67
|
+
let cancelOpenTimeout = useCallback(() => {
|
|
68
|
+
if (openTimeout.current) {
|
|
69
|
+
clearTimeout(openTimeout.current);
|
|
70
|
+
openTimeout.current = undefined;
|
|
71
|
+
}
|
|
72
|
+
}, [openTimeout]);
|
|
73
|
+
|
|
74
|
+
let onSubmenuOpen = useEffectEvent((focusStrategy?: FocusStrategy) => {
|
|
75
|
+
cancelOpenTimeout();
|
|
76
|
+
state.open(focusStrategy);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
let onSubmenuClose = useEffectEvent(() => {
|
|
80
|
+
cancelOpenTimeout();
|
|
81
|
+
state.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
useLayoutEffect(() => {
|
|
85
|
+
return () => {
|
|
86
|
+
cancelOpenTimeout();
|
|
87
|
+
};
|
|
88
|
+
}, [cancelOpenTimeout]);
|
|
89
|
+
|
|
90
|
+
let submenuKeyDown = (e: KeyboardEvent) => {
|
|
91
|
+
switch (e.key) {
|
|
92
|
+
case 'ArrowLeft':
|
|
93
|
+
if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) {
|
|
94
|
+
e.stopPropagation();
|
|
95
|
+
onSubmenuClose();
|
|
96
|
+
ref.current.focus();
|
|
97
|
+
}
|
|
98
|
+
break;
|
|
99
|
+
case 'ArrowRight':
|
|
100
|
+
if (direction === 'rtl' && e.currentTarget.contains(e.target as Element)) {
|
|
101
|
+
e.stopPropagation();
|
|
102
|
+
onSubmenuClose();
|
|
103
|
+
ref.current.focus();
|
|
104
|
+
}
|
|
105
|
+
break;
|
|
106
|
+
case 'Escape':
|
|
107
|
+
e.stopPropagation();
|
|
108
|
+
state.closeAll();
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
let submenuProps = {
|
|
114
|
+
id: overlayId,
|
|
115
|
+
'aria-label': node.textValue,
|
|
116
|
+
submenuLevel: state.submenuLevel,
|
|
117
|
+
...(type === 'menu' && {
|
|
118
|
+
onClose: state.closeAll,
|
|
119
|
+
autoFocus: state.focusStrategy,
|
|
120
|
+
onKeyDown: submenuKeyDown
|
|
121
|
+
})
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
let submenuTriggerKeyDown = (e: KeyboardEvent) => {
|
|
125
|
+
switch (e.key) {
|
|
126
|
+
case 'ArrowRight':
|
|
127
|
+
if (!isDisabled) {
|
|
128
|
+
if (direction === 'ltr') {
|
|
129
|
+
if (!state.isOpen) {
|
|
130
|
+
onSubmenuOpen('first');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
|
|
134
|
+
submenuRef.current.focus();
|
|
135
|
+
}
|
|
136
|
+
} else if (state.isOpen) {
|
|
137
|
+
onSubmenuClose();
|
|
138
|
+
} else {
|
|
139
|
+
e.continuePropagation();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
break;
|
|
144
|
+
case 'ArrowLeft':
|
|
145
|
+
if (!isDisabled) {
|
|
146
|
+
if (direction === 'rtl') {
|
|
147
|
+
if (!state.isOpen) {
|
|
148
|
+
onSubmenuOpen('first');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
|
|
152
|
+
submenuRef.current.focus();
|
|
153
|
+
}
|
|
154
|
+
} else if (state.isOpen) {
|
|
155
|
+
onSubmenuClose();
|
|
156
|
+
} else {
|
|
157
|
+
e.continuePropagation();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
161
|
+
case 'Escape':
|
|
162
|
+
state.closeAll();
|
|
163
|
+
break;
|
|
164
|
+
default:
|
|
165
|
+
e.continuePropagation();
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
let onPressStart = (e: PressEvent) => {
|
|
171
|
+
if (!isDisabled && (e.pointerType === 'virtual' || e.pointerType === 'keyboard')) {
|
|
172
|
+
// If opened with a screen reader or keyboard, auto focus the first submenu item.
|
|
173
|
+
onSubmenuOpen('first');
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
let onPress = (e: PressEvent) => {
|
|
178
|
+
if (!isDisabled && (e.pointerType === 'touch' || e.pointerType === 'mouse')) {
|
|
179
|
+
// For touch or on a desktop device with a small screen open on press up to possible problems with
|
|
180
|
+
// press up happening on the newly opened tray items
|
|
181
|
+
onSubmenuOpen();
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
let onHoverChange = (isHovered) => {
|
|
186
|
+
if (!isDisabled) {
|
|
187
|
+
if (isHovered && !state.isOpen) {
|
|
188
|
+
if (!openTimeout.current) {
|
|
189
|
+
openTimeout.current = setTimeout(() => {
|
|
190
|
+
onSubmenuOpen();
|
|
191
|
+
}, 200);
|
|
192
|
+
}
|
|
193
|
+
} else if (!isHovered) {
|
|
194
|
+
cancelOpenTimeout();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
let onBlur = (e) => {
|
|
200
|
+
if (state.isOpen && parentMenuRef.current.contains(e.relatedTarget)) {
|
|
201
|
+
onSubmenuClose();
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
let shouldCloseOnInteractOutside = (target) => {
|
|
206
|
+
if (target !== ref.current) {
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return false;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
useSafelyMouseToSubmenu({menuRef: parentMenuRef, submenuRef, isOpen: state.isOpen, isDisabled: isDisabled});
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
submenuTriggerProps: {
|
|
217
|
+
id: submenuTriggerId,
|
|
218
|
+
'aria-controls': state.isOpen ? overlayId : undefined,
|
|
219
|
+
'aria-haspopup': !isDisabled ? type : undefined,
|
|
220
|
+
'aria-expanded': state.isOpen ? 'true' : 'false',
|
|
221
|
+
onPressStart,
|
|
222
|
+
onPress,
|
|
223
|
+
onHoverChange,
|
|
224
|
+
onKeyDown: submenuTriggerKeyDown,
|
|
225
|
+
onBlur,
|
|
226
|
+
isOpen: state.isOpen
|
|
227
|
+
},
|
|
228
|
+
submenuProps,
|
|
229
|
+
popoverProps: {
|
|
230
|
+
isNonModal: true,
|
|
231
|
+
disableFocusManagement: true,
|
|
232
|
+
shouldCloseOnInteractOutside
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|