@react-aria/menu 3.17.0 → 3.18.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.
@@ -1,8 +1,8 @@
1
1
 
2
2
  import {RefObject} from '@react-types/shared';
3
3
  import {useEffect, useRef, useState} from 'react';
4
+ import {useEffectEvent, useResizeObserver} from '@react-aria/utils';
4
5
  import {useInteractionModality} from '@react-aria/interactions';
5
- import {useResizeObserver} from '@react-aria/utils';
6
6
 
7
7
  interface SafelyMouseToSubmenuOptions {
8
8
  /** Ref for the parent menu. */
@@ -51,6 +51,14 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions) {
51
51
 
52
52
  let modality = useInteractionModality();
53
53
 
54
+ // Prevent mouse down over safe triangle. Clicking while pointer-events: none is applied
55
+ // will cause focus to move unexpectedly since it will go to an element behind the menu.
56
+ let onPointerDown = useEffectEvent((e: PointerEvent) => {
57
+ if (preventPointerEvents) {
58
+ e.preventDefault();
59
+ }
60
+ });
61
+
54
62
  useEffect(() => {
55
63
  if (preventPointerEvents && menuRef.current) {
56
64
  (menuRef.current as HTMLElement).style.pointerEvents = 'none';
@@ -150,12 +158,21 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions) {
150
158
 
151
159
  window.addEventListener('pointermove', onPointerMove);
152
160
 
161
+ // Prevent pointer down over the safe triangle. See above comment.
162
+ // Do not enable in tests, because JSDom doesn't do hit testing.
163
+ if (process.env.NODE_ENV !== 'test') {
164
+ window.addEventListener('pointerdown', onPointerDown, true);
165
+ }
166
+
153
167
  return () => {
154
168
  window.removeEventListener('pointermove', onPointerMove);
169
+ if (process.env.NODE_ENV !== 'test') {
170
+ window.removeEventListener('pointerdown', onPointerDown, true);
171
+ }
155
172
  clearTimeout(timeout.current);
156
173
  clearTimeout(autoCloseTimeout.current);
157
174
  movementsTowardsSubmenuCount.current = ALLOWED_INVALID_MOVEMENTS;
158
175
  };
159
176
 
160
- }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, submenuRef]);
177
+ }, [isDisabled, isOpen, menuRef, modality, setPreventPointerEvents, onPointerDown, submenuRef]);
161
178
  }
@@ -14,9 +14,9 @@ import {AriaMenuItemProps} from './useMenuItem';
14
14
  import {AriaMenuOptions} from './useMenu';
15
15
  import type {AriaPopoverProps, OverlayProps} from '@react-aria/overlays';
16
16
  import {FocusableElement, FocusStrategy, KeyboardEvent, Node, PressEvent, RefObject} from '@react-types/shared';
17
+ import {focusWithoutScrolling, useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils';
17
18
  import type {SubmenuTriggerState} from '@react-stately/menu';
18
19
  import {useCallback, useRef} from 'react';
19
- import {useEffectEvent, useId, useLayoutEffect} from '@react-aria/utils';
20
20
  import {useLocale} from '@react-aria/i18n';
21
21
  import {useSafelyMouseToSubmenu} from './useSafelyMouseToSubmenu';
22
22
 
@@ -38,7 +38,9 @@ export interface AriaSubmenuTriggerProps {
38
38
  * The delay time in milliseconds for the submenu to appear after hovering over the trigger.
39
39
  * @default 200
40
40
  */
41
- delay?: number
41
+ delay?: number,
42
+ /** Whether the submenu trigger uses virtual focus. */
43
+ shouldUseVirtualFocus?: boolean
42
44
  }
43
45
 
44
46
  interface SubmenuTriggerProps extends Omit<AriaMenuItemProps, 'key'> {
@@ -67,7 +69,7 @@ export interface SubmenuTriggerAria<T> {
67
69
  * @param ref - Ref to the submenu trigger element.
68
70
  */
69
71
  export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: SubmenuTriggerState, ref: RefObject<FocusableElement | null>): SubmenuTriggerAria<T> {
70
- let {parentMenuRef, submenuRef, type = 'menu', isDisabled, delay = 200} = props;
72
+ let {parentMenuRef, submenuRef, type = 'menu', isDisabled, delay = 200, shouldUseVirtualFocus} = props;
71
73
  let submenuTriggerId = useId();
72
74
  let overlayId = useId();
73
75
  let {direction} = useLocale();
@@ -96,24 +98,42 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
96
98
  }, [cancelOpenTimeout]);
97
99
 
98
100
  let submenuKeyDown = (e: KeyboardEvent) => {
101
+ // If focus is not within the menu, assume virtual focus is being used.
102
+ // This means some other input element is also within the popover, so we shouldn't close the menu.
103
+ if (!e.currentTarget.contains(document.activeElement)) {
104
+ return;
105
+ }
106
+
99
107
  switch (e.key) {
100
108
  case 'ArrowLeft':
101
109
  if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) {
110
+ e.preventDefault();
102
111
  e.stopPropagation();
103
112
  onSubmenuClose();
104
- ref.current?.focus();
113
+ if (!shouldUseVirtualFocus && ref.current) {
114
+ focusWithoutScrolling(ref.current);
115
+ }
105
116
  }
106
117
  break;
107
118
  case 'ArrowRight':
108
119
  if (direction === 'rtl' && e.currentTarget.contains(e.target as Element)) {
120
+ e.preventDefault();
109
121
  e.stopPropagation();
110
122
  onSubmenuClose();
111
- ref.current?.focus();
123
+ if (!shouldUseVirtualFocus && ref.current) {
124
+ focusWithoutScrolling(ref.current);
125
+ }
112
126
  }
113
127
  break;
114
128
  case 'Escape':
115
- e.stopPropagation();
116
- state.closeAll();
129
+ // TODO: can remove this when we fix collection event leaks
130
+ if (submenuRef.current?.contains(e.target as Element)) {
131
+ e.stopPropagation();
132
+ onSubmenuClose();
133
+ if (!shouldUseVirtualFocus && ref.current) {
134
+ focusWithoutScrolling(ref.current);
135
+ }
136
+ }
117
137
  break;
118
138
  }
119
139
  };
@@ -134,12 +154,13 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
134
154
  case 'ArrowRight':
135
155
  if (!isDisabled) {
136
156
  if (direction === 'ltr') {
157
+ e.preventDefault();
137
158
  if (!state.isOpen) {
138
159
  onSubmenuOpen('first');
139
160
  }
140
161
 
141
162
  if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
142
- submenuRef.current.focus();
163
+ focusWithoutScrolling(submenuRef.current);
143
164
  }
144
165
  } else if (state.isOpen) {
145
166
  onSubmenuClose();
@@ -152,12 +173,13 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
152
173
  case 'ArrowLeft':
153
174
  if (!isDisabled) {
154
175
  if (direction === 'rtl') {
176
+ e.preventDefault();
155
177
  if (!state.isOpen) {
156
178
  onSubmenuOpen('first');
157
179
  }
158
180
 
159
181
  if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
160
- submenuRef.current.focus();
182
+ focusWithoutScrolling(submenuRef.current);
161
183
  }
162
184
  } else if (state.isOpen) {
163
185
  onSubmenuClose();
@@ -166,9 +188,6 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
166
188
  }
167
189
  }
168
190
  break;
169
- case 'Escape':
170
- state.closeAll();
171
- break;
172
191
  default:
173
192
  e.continuePropagation();
174
193
  break;
@@ -205,7 +224,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
205
224
  };
206
225
 
207
226
  let onBlur = (e) => {
208
- if (state.isOpen && parentMenuRef.current?.contains(e.relatedTarget)) {
227
+ if (state.isOpen && (parentMenuRef.current?.contains(e.relatedTarget))) {
209
228
  onSubmenuClose();
210
229
  }
211
230
  };
@@ -236,7 +255,6 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
236
255
  submenuProps,
237
256
  popoverProps: {
238
257
  isNonModal: true,
239
- disableFocusManagement: true,
240
258
  shouldCloseOnInteractOutside
241
259
  }
242
260
  };