@react-aria/menu 3.19.4 → 3.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,9 +11,9 @@
11
11
  */
12
12
 
13
13
  import {DOMAttributes, DOMProps, FocusableElement, FocusEvents, HoverEvents, Key, KeyboardEvents, PressEvent, PressEvents, RefObject} from '@react-types/shared';
14
- import {filterDOMProps, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils';
14
+ import {filterDOMProps, getEventTarget, handleLinkClick, mergeProps, useLinkProps, useRouter, useSlotId} from '@react-aria/utils';
15
15
  import {getItemCount} from '@react-stately/collections';
16
- import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions';
16
+ import {isFocusVisible, setInteractionModality, useFocusable, useHover, useKeyboard, usePress} from '@react-aria/interactions';
17
17
  import {menuData} from './utils';
18
18
  import {MouseEvent, useRef} from 'react';
19
19
  import {SelectionManager} from '@react-stately/selection';
@@ -72,10 +72,13 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K
72
72
 
73
73
  /**
74
74
  * Whether the menu should close when the menu item is selected.
75
- * @default true
75
+ * @deprecated - use shouldCloseOnSelect instead.
76
76
  */
77
77
  closeOnSelect?: boolean,
78
78
 
79
+ /** Whether the menu should close when the menu item is selected. */
80
+ shouldCloseOnSelect?: boolean,
81
+
79
82
  /** Whether the menu item is contained in a virtual scrolling menu. */
80
83
  isVirtualized?: boolean,
81
84
 
@@ -94,6 +97,9 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K
94
97
  /** Identifies the menu item's popup element whose contents or presence is controlled by the menu item. */
95
98
  'aria-controls'?: string,
96
99
 
100
+ /** Identifies the element(s) that describe the menu item. */
101
+ 'aria-describedby'?: string,
102
+
97
103
  /** Override of the selection manager. By default, `state.selectionManager` is used. */
98
104
  selectionManager?: SelectionManager
99
105
  }
@@ -109,6 +115,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
109
115
  id,
110
116
  key,
111
117
  closeOnSelect,
118
+ shouldCloseOnSelect,
112
119
  isVirtualized,
113
120
  'aria-haspopup': hasPopup,
114
121
  onPressStart,
@@ -173,7 +180,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
173
180
  role,
174
181
  'aria-label': props['aria-label'],
175
182
  'aria-labelledby': labelId,
176
- 'aria-describedby': [descriptionId, keyboardId].filter(Boolean).join(' ') || undefined,
183
+ 'aria-describedby': [props['aria-describedby'], descriptionId, keyboardId].filter(Boolean).join(' ') || undefined,
177
184
  'aria-controls': props['aria-controls'],
178
185
  'aria-haspopup': hasPopup,
179
186
  'aria-expanded': props['aria-expanded']
@@ -184,7 +191,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
184
191
  }
185
192
 
186
193
  if (isVirtualized) {
187
- ariaProps['aria-posinset'] = item?.index;
194
+ let index = Number(item?.index);
195
+ ariaProps['aria-posinset'] = Number.isNaN(index) ? undefined : index + 1;
188
196
  ariaProps['aria-setsize'] = getItemCount(state.collection);
189
197
  }
190
198
 
@@ -221,8 +229,10 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
221
229
  ? interaction.current?.key === 'Enter' || selectionManager.selectionMode === 'none' || selectionManager.isLink(key)
222
230
  // Close except if multi-select is enabled.
223
231
  : selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key);
224
-
225
- shouldClose = closeOnSelect ?? shouldClose;
232
+
233
+
234
+ shouldClose = shouldCloseOnSelect ?? closeOnSelect ?? shouldClose;
235
+
226
236
  if (onClose && !isTrigger && shouldClose) {
227
237
  onClose();
228
238
  }
@@ -279,15 +289,23 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
279
289
  switch (e.key) {
280
290
  case ' ':
281
291
  interaction.current = {pointerType: 'keyboard', key: ' '};
282
- (e.target as HTMLElement).click();
292
+ (getEventTarget(e) as HTMLElement).click();
293
+
294
+ // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus
295
+ // to the newly opened submenu's first item.
296
+ setInteractionModality('keyboard');
283
297
  break;
284
298
  case 'Enter':
285
299
  interaction.current = {pointerType: 'keyboard', key: 'Enter'};
286
300
 
287
301
  // Trigger click unless this is a link. Links trigger click natively.
288
- if ((e.target as HTMLElement).tagName !== 'A') {
289
- (e.target as HTMLElement).click();
302
+ if ((getEventTarget(e) as HTMLElement).tagName !== 'A') {
303
+ (getEventTarget(e) as HTMLElement).click();
290
304
  }
305
+
306
+ // click above sets modality to "virtual", need to set interaction modality back to 'keyboard' so focusSafely calls properly move focus
307
+ // to the newly opened submenu's first item.
308
+ setInteractionModality('keyboard');
291
309
  break;
292
310
  default:
293
311
  if (!isTrigger) {
@@ -301,7 +319,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
301
319
  onKeyUp
302
320
  });
303
321
 
304
- let {focusProps} = useFocus({onBlur, onFocus, onFocusChange});
322
+ let {focusableProps} = useFocusable({onBlur, onFocus, onFocusChange}, ref);
305
323
  let domProps = filterDOMProps(item?.props);
306
324
  delete domProps.id;
307
325
  let linkProps = useLinkProps(item?.props);
@@ -312,13 +330,13 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
312
330
  ...mergeProps(
313
331
  domProps,
314
332
  linkProps,
315
- isTrigger
316
- ? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']}
333
+ isTrigger
334
+ ? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']}
317
335
  : itemProps,
318
336
  pressProps,
319
337
  hoverProps,
320
338
  keyboardProps,
321
- focusProps,
339
+ focusableProps,
322
340
  // Prevent DOM focus from moving on mouse down when using virtual focus or this is a submenu/subdialog trigger.
323
341
  data.shouldUseVirtualFocus || isTrigger ? {onMouseDown: e => e.preventDefault()} : undefined,
324
342
  isDisabled ? undefined : {onClick}
@@ -1,7 +1,7 @@
1
1
 
2
+ import {nodeContains, useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
2
3
  import {RefObject} from '@react-types/shared';
3
4
  import {useEffect, useRef, useState} from 'react';
4
- import {useEffectEvent, useLayoutEffect, useResizeObserver} from '@react-aria/utils';
5
5
  import {useInteractionModality} from '@react-aria/interactions';
6
6
 
7
7
  interface SafelyMouseToSubmenuOptions {
@@ -41,7 +41,7 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v
41
41
  submenuSide.current = undefined;
42
42
  }
43
43
  };
44
- useResizeObserver({ref: submenuRef, onResize: updateSubmenuRect});
44
+ useResizeObserver({ref: isOpen ? submenuRef : undefined, onResize: updateSubmenuRect});
45
45
 
46
46
  let reset = () => {
47
47
  setPreventPointerEvents(false);
@@ -148,7 +148,7 @@ export function useSafelyMouseToSubmenu(options: SafelyMouseToSubmenuOptions): v
148
148
  // Fire a pointerover event to trigger the menu to close.
149
149
  // Wait until pointer-events:none is no longer applied
150
150
  let target = document.elementFromPoint(mouseX, mouseY);
151
- if (target && menu.contains(target)) {
151
+ if (target && nodeContains(menu, target)) {
152
152
  target.dispatchEvent(new PointerEvent('pointerover', {bubbles: true, cancelable: true}));
153
153
  }
154
154
  }, 100);
@@ -14,7 +14,7 @@ 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, useEvent, useId, useLayoutEffect} from '@react-aria/utils';
17
+ import {focusWithoutScrolling, getActiveElement, getEventTarget, isFocusWithin, nodeContains, useEvent, useId, useLayoutEffect} from '@react-aria/utils';
18
18
  import type {SubmenuTriggerState} from '@react-stately/menu';
19
19
  import {useCallback, useRef} from 'react';
20
20
  import {useLocale} from '@react-aria/i18n';
@@ -43,7 +43,7 @@ export interface AriaSubmenuTriggerProps {
43
43
  shouldUseVirtualFocus?: boolean
44
44
  }
45
45
 
46
- interface SubmenuTriggerProps extends Omit<AriaMenuItemProps, 'key'> {
46
+ interface SubmenuTriggerProps extends Omit<AriaMenuItemProps, 'key' | 'onAction'> {
47
47
  /** Whether the submenu trigger is in an expanded state. */
48
48
  isOpen: boolean
49
49
  }
@@ -100,13 +100,13 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
100
100
  let submenuKeyDown = (e: KeyboardEvent) => {
101
101
  // If focus is not within the menu, assume virtual focus is being used.
102
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)) {
103
+ if (!isFocusWithin(e.currentTarget)) {
104
104
  return;
105
105
  }
106
106
 
107
107
  switch (e.key) {
108
108
  case 'ArrowLeft':
109
- if (direction === 'ltr' && e.currentTarget.contains(e.target as Element)) {
109
+ if (direction === 'ltr' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
110
110
  e.preventDefault();
111
111
  e.stopPropagation();
112
112
  onSubmenuClose();
@@ -116,7 +116,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
116
116
  }
117
117
  break;
118
118
  case 'ArrowRight':
119
- if (direction === 'rtl' && e.currentTarget.contains(e.target as Element)) {
119
+ if (direction === 'rtl' && nodeContains(e.currentTarget, getEventTarget(e) as Element)) {
120
120
  e.preventDefault();
121
121
  e.stopPropagation();
122
122
  onSubmenuClose();
@@ -127,7 +127,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
127
127
  break;
128
128
  case 'Escape':
129
129
  // TODO: can remove this when we fix collection event leaks
130
- if (submenuRef.current?.contains(e.target as Element)) {
130
+ if (nodeContains(submenuRef.current, getEventTarget(e) as Element)) {
131
131
  e.stopPropagation();
132
132
  onSubmenuClose();
133
133
  if (!shouldUseVirtualFocus && ref.current) {
@@ -159,7 +159,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
159
159
  onSubmenuOpen('first');
160
160
  }
161
161
 
162
- if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
162
+ if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
163
163
  focusWithoutScrolling(submenuRef.current);
164
164
  }
165
165
  } else if (state.isOpen) {
@@ -178,7 +178,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
178
178
  onSubmenuOpen('first');
179
179
  }
180
180
 
181
- if (type === 'menu' && !!submenuRef?.current && document.activeElement === ref?.current) {
181
+ if (type === 'menu' && !!submenuRef?.current && getActiveElement() === ref?.current) {
182
182
  focusWithoutScrolling(submenuRef.current);
183
183
  }
184
184
  } else if (state.isOpen) {
@@ -226,7 +226,7 @@ export function useSubmenuTrigger<T>(props: AriaSubmenuTriggerProps, state: Subm
226
226
  useEvent(parentMenuRef, 'focusin', (e) => {
227
227
  // If we detect focus moved to a different item in the same menu that the currently open submenu trigger is in
228
228
  // then close the submenu. This is for a case where the user hovers a root menu item when multiple submenus are open
229
- if (state.isOpen && (parentMenuRef.current?.contains(e.target as HTMLElement) && e.target !== ref.current)) {
229
+ if (state.isOpen && (nodeContains(parentMenuRef.current, getEventTarget(e) as HTMLElement) && getEventTarget(e) !== ref.current)) {
230
230
  onSubmenuClose();
231
231
  }
232
232
  });