Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -109,6 +112,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
id,
key,
closeOnSelect,
shouldCloseOnSelect,
isVirtualized,
'aria-haspopup': hasPopup,
onPressStart,
Expand Down Expand Up @@ -221,8 +225,10 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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();
}
Expand Down Expand Up @@ -312,8 +318,8 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, 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,
Expand Down
19 changes: 14 additions & 5 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ export interface MenuProps<T> extends Omit<AriaMenuProps<T>, 'children'>, Collec
*/
className?: ClassNameOrFunction<MenuRenderProps>,
/** 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
}

/**
Expand Down Expand Up @@ -267,7 +269,7 @@ function MenuInner<T extends object>({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],
Expand All @@ -294,7 +296,9 @@ export interface MenuSectionProps<T> extends SectionProps<T>, 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,
Expand Down Expand Up @@ -347,6 +351,8 @@ function MenuSectionInner<T extends object>(props: MenuSectionProps<T>, 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;

Expand All @@ -357,7 +363,8 @@ function MenuSectionInner<T extends object>(props: MenuSectionProps<T>, ref: For
<Provider
values={[
[HeaderContext, {...headingProps, ref: headingRef}],
[SelectionManagerContext, manager]
[SelectionManagerContext, manager],
[MenuItemContext, {shouldCloseOnSelect: props.shouldCloseOnSelect ?? closeOnSelect}]
]}>
<CollectionBranch collection={state.collection} parent={section} />
</Provider>
Expand Down Expand Up @@ -402,7 +409,9 @@ export interface MenuItemProps<T = object> extends RenderProps<MenuItemRenderPro
/** Whether the item is disabled. */
isDisabled?: boolean,
/** Handler that is called when the item is selected. */
onAction?: () => void
onAction?: () => void,
/** Whether the menu should close when the menu item is selected. */
shouldCloseOnSelect?: boolean
}

const MenuItemContext = createContext<ContextValue<MenuItemProps, HTMLDivElement>>(null);
Expand Down
113 changes: 113 additions & 0 deletions packages/react-aria-components/test/Menu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MenuTrigger>
<Button aria-label="Menu">☰</Button>
<Popover>
<Menu shouldCloseOnSelect={false}>
<MenuItem id="open">Open</MenuItem>
<MenuItem id="rename">Rename…</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuItem id="share">Share…</MenuItem>
<MenuItem id="delete">Delete…</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
);

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(
<MenuTrigger>
<Button aria-label="Menu">☰</Button>
<Popover>
<Menu>
<MenuItem id="open" shouldCloseOnSelect={false}>Open</MenuItem>
<MenuItem id="rename">Rename…</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
<MenuItem id="share">Share…</MenuItem>
<MenuItem id="delete">Delete…</MenuItem>
</Menu>
</Popover>
</MenuTrigger>
);

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(
<MenuTrigger>
<Button aria-label="Menu">☰</Button>
<Popover>
<Menu>
<MenuSection shouldCloseOnSelect={false}>
<MenuItem id="open">Open</MenuItem>
<MenuItem id="rename">Rename…</MenuItem>
<MenuItem id="duplicate">Duplicate</MenuItem>
</MenuSection>
<MenuSection>
<MenuItem id="share">Share…</MenuItem>
<MenuItem id="delete">Delete…</MenuItem>
</MenuSection>
</Menu>
</Popover>
</MenuTrigger>
);

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) {
Expand Down