diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 30440abd5d7..58881c84064 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -72,10 +72,13 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K /** * Whether the menu should close when the menu item is selected. - * @default true + * @deprecated - use shouldCloseOnSelect instead. */ closeOnSelect?: boolean, + /** Whether the menu should close when the menu item is selected. */ + shouldCloseOnSelect?: boolean, + /** Whether the menu item is contained in a virtual scrolling menu. */ isVirtualized?: boolean, @@ -109,6 +112,7 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re id, key, closeOnSelect, + shouldCloseOnSelect, isVirtualized, 'aria-haspopup': hasPopup, onPressStart, @@ -221,8 +225,10 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re ? interaction.current?.key === 'Enter' || selectionManager.selectionMode === 'none' || selectionManager.isLink(key) // Close except if multi-select is enabled. : selectionManager.selectionMode !== 'multiple' || selectionManager.isLink(key); - - shouldClose = closeOnSelect ?? shouldClose; + + + shouldClose = shouldCloseOnSelect ?? closeOnSelect ?? shouldClose; + if (onClose && !isTrigger && shouldClose) { onClose(); } @@ -312,8 +318,8 @@ export function useMenuItem(props: AriaMenuItemProps, state: TreeState, re ...mergeProps( domProps, linkProps, - isTrigger - ? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']} + isTrigger + ? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']} : itemProps, pressProps, hoverProps, diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 292b1815132..187c3884374 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -194,7 +194,9 @@ export interface MenuProps extends Omit, 'children'>, Collec */ className?: ClassNameOrFunction, /** Provides content to display when there are no items in the list. */ - renderEmptyState?: () => ReactNode + renderEmptyState?: () => ReactNode, + /** Whether the menu should close when the menu item is selected. */ + shouldCloseOnSelect?: boolean } /** @@ -267,7 +269,7 @@ function MenuInner({props, collection, menuRef: ref}: MenuInne [SeparatorContext, {elementType: 'div'}], [SectionContext, {name: 'MenuSection', render: MenuSectionInner}], [SubmenuTriggerContext, {parentMenuRef: ref, shouldUseVirtualFocus: autocompleteMenuProps?.shouldUseVirtualFocus}], - [MenuItemContext, null], + [MenuItemContext, {shouldCloseOnSelect: props.shouldCloseOnSelect}], [SelectableCollectionContext, null], [FieldInputContext, null], [SelectionManagerContext, state.selectionManager], @@ -294,7 +296,9 @@ export interface MenuSectionProps extends SectionProps, MultipleSelection * The CSS [className](https://developer.mozilla.org/en-US/docs/Web/API/Element/className) for the element. * @default 'react-aria-MenuSection' */ - className?: string + className?: string, + /** Whether the menu should close when the menu item is selected. */ + shouldCloseOnSelect?: boolean } // A subclass of SelectionManager that forwards focus-related properties to the parent, @@ -347,6 +351,8 @@ function MenuSectionInner(props: MenuSectionProps, ref: For let selectionState = useMultipleSelectionState(props); let manager = props.selectionMode != null ? new GroupSelectionManager(parent, selectionState) : parent; + let closeOnSelect = useSlottedContext(MenuItemContext)?.shouldCloseOnSelect; + let DOMProps = filterDOMProps(props as any, {global: true}); delete DOMProps.id; @@ -357,7 +363,8 @@ function MenuSectionInner(props: MenuSectionProps, ref: For @@ -402,7 +409,9 @@ export interface MenuItemProps extends RenderProps void + onAction?: () => void, + /** Whether the menu should close when the menu item is selected. */ + shouldCloseOnSelect?: boolean } const MenuItemContext = createContext>(null); diff --git a/packages/react-aria-components/test/Menu.test.tsx b/packages/react-aria-components/test/Menu.test.tsx index 4ff56edafbf..6a3fadaca31 100644 --- a/packages/react-aria-components/test/Menu.test.tsx +++ b/packages/react-aria-components/test/Menu.test.tsx @@ -510,6 +510,119 @@ describe('Menu', () => { expect(menuitem).toHaveTextContent('No results'); }); + it('should not close the menu when shouldCloseOnSelect is false', async () => { + let {queryByRole, getByRole, getAllByRole} = render( + + + + + Open + Rename… + Duplicate + Share… + Delete… + + + + ); + + expect(queryByRole('menu')).not.toBeInTheDocument(); + + let button = getByRole('button'); + await user.click(button); + + let menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + let items = getAllByRole('menuitem'); + expect(items).toHaveLength(5); + + let item = items[0]; + expect(item).toHaveTextContent('Open'); + await user.click(item); + expect(menu).toBeInTheDocument(); + }); + + it('should not close individual menu item when shouldCloseOnSelect=false', async () => { + let {queryByRole, getByRole, getAllByRole} = render( + + + + + Open + Rename… + Duplicate + Share… + Delete… + + + + ); + + expect(queryByRole('menu')).not.toBeInTheDocument(); + + let button = getByRole('button'); + await user.click(button); + + let menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + let items = getAllByRole('menuitem'); + expect(items).toHaveLength(5); + + let item = items[0]; + expect(item).toHaveTextContent('Open'); + await user.click(item); + expect(menu).toBeInTheDocument(); + + item = items[1]; + expect(item).toHaveTextContent('Rename'); + await user.click(item); + expect(menu).not.toBeInTheDocument(); + }); + + it('should not close menu items within a section when shouldCloseOnSelect=false', async () => { + let {queryByRole, getByRole, getAllByRole} = render( + + + + + + Open + Rename… + Duplicate + + + Share… + Delete… + + + + + ); + + expect(queryByRole('menu')).not.toBeInTheDocument(); + + let button = getByRole('button'); + await user.click(button); + + let menu = getByRole('menu'); + expect(menu).toBeInTheDocument(); + + let items = getAllByRole('menuitem'); + expect(items).toHaveLength(5); + + let item = items[0]; + expect(item).toHaveTextContent('Open'); + await user.click(item); + expect(menu).toBeInTheDocument(); + + item = items[3]; + expect(item).toHaveTextContent('Share'); + await user.click(item); + expect(menu).not.toBeInTheDocument(); + }); + describe('supports links', function () { describe.each(['mouse', 'keyboard'])('%s', (type) => { it.each(['none', 'single', 'multiple'] as unknown as SelectionMode[])('with selectionMode = %s', async function (selectionMode) {