@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.
@@ -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, useEffectEvent, useLayoutEffect, useRouter, useSlotId} from '@react-aria/utils';
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, useCallback, useRef} from 'react';
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 onActionMenuDialogTrigger = useCallback(() => {
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 (state.selectionManager.selectionMode === 'single') {
150
- role = 'menuitemradio';
151
- } else if (state.selectionManager.selectionMode === 'multiple') {
152
- role = 'menuitemcheckbox';
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
- isDisabled: isDisabled || (isTrigger && state.expandedKeys.has(key))
212
+ onPressChange,
213
+ onPressEnd,
214
+ isDisabled
217
215
  });
218
216
  let {hoverProps} = useHover({
219
217
  isDisabled,
220
- onHoverStart() {
221
- if (!isFocusVisible() && !(isTrigger && state.expandedKeys.has(key))) {
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: isHovered => {
232
- if (isHovered && isTrigger && !state.expandedKeys.has(key)) {
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
- case 'ArrowRight':
266
- if (isTrigger && direction === 'ltr') {
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
- break;
279
- default:
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
+ }