Skip to content

Commit afcf1e9

Browse files
authored
fix: Pressing Enter key to trigger Menu item links (#8721)
1 parent 42e5a2c commit afcf1e9

File tree

2 files changed

+74
-29
lines changed

2 files changed

+74
-29
lines changed

packages/@react-aria/menu/src/useMenuItem.ts

Lines changed: 26 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
111111
closeOnSelect,
112112
isVirtualized,
113113
'aria-haspopup': hasPopup,
114-
onPressStart: pressStartProp,
114+
onPressStart,
115115
onPressUp: pressUpProp,
116116
onPress,
117117
onPressChange: pressChangeProp,
@@ -188,21 +188,18 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
188188
ariaProps['aria-setsize'] = getItemCount(state.collection);
189189
}
190190

191-
let onPressStart = (e: PressEvent) => {
192-
// Trigger native click event on keydown unless this is a link (the browser will trigger onClick then).
193-
if (e.pointerType === 'keyboard' && !selectionManager.isLink(key)) {
194-
(e.target as HTMLElement).click();
195-
}
196-
197-
pressStartProp?.(e);
198-
};
199191
let isPressedRef = useRef(false);
200192
let onPressChange = (isPressed: boolean) => {
201193
pressChangeProp?.(isPressed);
202194
isPressedRef.current = isPressed;
203195
};
204196

197+
let interaction = useRef<{pointerType: string, key?: string} | null>(null);
205198
let onPressUp = (e: PressEvent) => {
199+
if (e.pointerType !== 'keyboard') {
200+
interaction.current = {pointerType: e.pointerType};
201+
}
202+
206203
// If interacting with mouse, allow the user to mouse down on the trigger button,
207204
// drag, and release over an item (matching native behavior).
208205
if (e.pointerType === 'mouse') {
@@ -211,19 +208,26 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
211208
}
212209
}
213210

214-
// Pressing a menu item should close by default in single selection mode but not multiple
215-
// selection mode, except if overridden by the closeOnSelect prop.
216-
if (e.pointerType !== 'keyboard' && !isTrigger && onClose && (closeOnSelect ?? (selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key)))) {
217-
onClose();
218-
}
219-
220211
pressUpProp?.(e);
221212
};
222213

223214
let onClick = (e: MouseEvent<FocusableElement>) => {
224215
onClickProp?.(e);
225216
performAction();
226217
handleLinkClick(e, router, item!.props.href, item?.props.routerOptions);
218+
219+
let shouldClose = interaction.current?.pointerType === 'keyboard'
220+
// Always close when pressing Enter key, or if item is not selectable.
221+
? interaction.current?.key === 'Enter' || selectionManager.selectionMode === 'none' || selectionManager.isLink(key)
222+
// Close except if multi-select is enabled.
223+
: selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key);
224+
225+
shouldClose = closeOnSelect ?? shouldClose;
226+
if (onClose && !isTrigger && shouldClose) {
227+
onClose();
228+
}
229+
230+
interaction.current = null;
227231
};
228232

229233
let {itemProps, isFocused} = useSelectableItem({
@@ -274,14 +278,15 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
274278

275279
switch (e.key) {
276280
case ' ':
277-
if (!isDisabled && selectionManager.selectionMode === 'none' && !isTrigger && closeOnSelect !== false && onClose) {
278-
onClose();
279-
}
281+
interaction.current = {pointerType: 'keyboard', key: ' '};
282+
(e.target as HTMLElement).click();
280283
break;
281284
case 'Enter':
282-
// The Enter key should always close on select, except if overridden.
283-
if (!isDisabled && closeOnSelect !== false && !isTrigger && onClose) {
284-
onClose();
285+
interaction.current = {pointerType: 'keyboard', key: 'Enter'};
286+
287+
// 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();
285290
}
286291
break;
287292
default:

packages/@react-spectrum/menu/test/Menu.test.js

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212

1313
import {act, fireEvent, mockClickDefault, pointerMap, render, within} from '@react-spectrum/test-utils-internal';
1414
import Bell from '@spectrum-icons/workflow/Bell';
15+
import {Button} from '@react-spectrum/button';
1516
import {Dialog, DialogTrigger} from '@react-spectrum/dialog';
16-
import {Item, Menu, Section} from '../';
17+
import {Item, Menu, MenuTrigger, Section} from '../';
1718
import {Keyboard, Text} from '@react-spectrum/text';
1819
import {MenuContext} from '../src/context';
1920
import {Provider} from '@react-spectrum/provider';
@@ -789,20 +790,31 @@ describe('Menu', function () {
789790
});
790791

791792
describe('supports links', function () {
792-
describe.each(['mouse', 'keyboard'])('%s', (type) => {
793+
describe.each(['mouse', 'Enter', 'Space'])('%s', (type) => {
793794
it.each(['none', 'single', 'multiple'])('with selectionMode = %s', async function (selectionMode) {
794795
let user = userEvent.setup({delay: null, pointerMap});
795796
let onAction = jest.fn();
796797
let onSelectionChange = jest.fn();
797798
let tree = render(
798799
<Provider theme={theme}>
799-
<Menu aria-label="menu" selectionMode={selectionMode} onSelectionChange={onSelectionChange} onAction={onAction}>
800-
<Item href="https://google.com">One</Item>
801-
<Item href="https://adobe.com">Two</Item>
802-
</Menu>
800+
<MenuTrigger>
801+
<Button>Button</Button>
802+
<Menu aria-label="menu" selectionMode={selectionMode} onSelectionChange={onSelectionChange} onAction={onAction}>
803+
<Item href="https://google.com">One</Item>
804+
<Item href="https://adobe.com">Two</Item>
805+
</Menu>
806+
</MenuTrigger>
803807
</Provider>
804808
);
805809

810+
let button = tree.getByRole('button');
811+
if (type === 'mouse') {
812+
await user.click(button);
813+
} else {
814+
await user.tab();
815+
await user.keyboard('{Enter}');
816+
}
817+
806818
let role = {
807819
none: 'menuitem',
808820
single: 'menuitemradio',
@@ -820,15 +832,43 @@ describe('Menu', function () {
820832
if (type === 'mouse') {
821833
await user.click(items[1]);
822834
} else {
823-
fireEvent.keyDown(items[1], {key: 'Enter'});
835+
fireEvent.keyDown(items[1], {key: type});
824836
fireEvent.click(items[1]);
825-
fireEvent.keyUp(items[1], {key: 'Enter'});
837+
fireEvent.keyUp(items[1], {key: type});
826838
}
827839
expect(onAction).toHaveBeenCalledTimes(1);
828840
expect(onSelectionChange).not.toHaveBeenCalled();
829841
expect(onClick).toHaveBeenCalledTimes(1);
830842
window.removeEventListener('click', onClick);
831843
});
832844
});
845+
846+
it('should support dragging and releasing', async () => {
847+
let user = userEvent.setup({delay: null, pointerMap});
848+
let onAction = jest.fn();
849+
let tree = render(
850+
<Provider theme={theme}>
851+
<MenuTrigger>
852+
<Button>Button</Button>
853+
<Menu aria-label="menu" onAction={onAction}>
854+
<Item href="https://google.com">One</Item>
855+
<Item href="https://adobe.com">Two</Item>
856+
</Menu>
857+
</MenuTrigger>
858+
</Provider>
859+
);
860+
861+
let button = tree.getByRole('button');
862+
await user.pointer({target: button, keys: '[MouseLeft>]'});
863+
864+
let items = tree.getAllByRole('menuitem');
865+
let onClick = mockClickDefault({capture: true});
866+
867+
await user.pointer({target: items[0], keys: '[/MouseLeft]'});
868+
869+
expect(onAction).toHaveBeenCalledTimes(1);
870+
expect(onClick).toHaveBeenCalledTimes(1);
871+
window.removeEventListener('click', onClick);
872+
});
833873
});
834874
});

0 commit comments

Comments
 (0)