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
29 changes: 29 additions & 0 deletions packages/react/src/context-menu/root/ContextMenuRoot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,35 @@ describe('<ContextMenu.Root />', () => {
expect(surface).toHaveFocus();
});

it('returns focus to the focused surface when closing with Tab', async () => {
const { user } = await render(
<ContextMenu.Root>
<ContextMenu.Trigger render={<button />}>Surface</ContextMenu.Trigger>
<ContextMenu.Portal>
<ContextMenu.Positioner>
<ContextMenu.Popup>
<ContextMenu.Item>Item</ContextMenu.Item>
</ContextMenu.Popup>
</ContextMenu.Positioner>
</ContextMenu.Portal>
</ContextMenu.Root>,
);

const surface = screen.getByRole('button', { name: 'Surface' });
await user.tab();
expect(surface).toHaveFocus();
fireEvent.contextMenu(surface, { clientX: 20, clientY: 20, button: 2 });
await waitFor(() => {
expect(screen.getByRole('menu')).toHaveFocus();
});

await user.tab();
await waitFor(() => {
expect(screen.queryByRole('menu')).toBe(null);
});
expect(surface).toHaveFocus();
});

describe.skipIf(isJSDOM)('prop: collisionAvoidance', () => {
const popupHeight = 100;
const popupWidth = 150;
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/menu/root/MenuRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,9 @@ export const MenuRootInternal = fastComponent(function MenuRootInternal<Payload>
// on both menus' orientations; the generic arrow-key opening would also react to the parent's
// forwarded cross-axis keys while closed.
openOnArrowKeyDown: parent.type !== 'context-menu' && !(virtualFocus && isSubmenu),
// A root context menu's focus manager is modal, so its guard swallows Tab when no item is
// tabbable. Close it here instead, as a regular menu closes when Tab moves focus out.
closeOnTab: parent.type === 'context-menu',
externalTree: !virtualFocus && nested ? floatingTreeRoot : undefined,
nestedReturnFocusRef: parentMenuStore?.context.virtualFocusRef,
focusItemOnHover: highlightItemOnHover,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,11 @@ export interface UseListNavigationProps {
* Computes two-dimensional list navigation for grid-capable consumers.
*/
grid?: typeof gridNavigation | null | undefined;
/**
* Whether Tab closes the open list the same way Shift+Tab does.
* @default false
*/
closeOnTab?: boolean | undefined;
}

/**
Expand Down Expand Up @@ -258,6 +263,7 @@ export function useListNavigation(
externalTree,
nestedReturnFocusRef,
grid: navigateGrid,
closeOnTab = false,
} = props;

const isGrid = navigateGrid != null;
Expand Down Expand Up @@ -761,8 +767,8 @@ export function useListNavigation(
return {
...(!typeableComboboxReference ? ariaActiveDescendantProp : {}),
onKeyDown(event: React.KeyboardEvent) {
// Close submenu on Shift+Tab
if (event.key === 'Tab' && event.shiftKey && open && !virtual) {
// Close on Shift+Tab, and on Tab when `closeOnTab` is set.
if (event.key === 'Tab' && (event.shiftKey || closeOnTab) && open && !virtual) {
// If the event originated from within a nested element (e.g., a Dialog opened from
// within the menu), don't close the menu. The nested element has its own focus
// management and should handle the Tab key.
Expand Down Expand Up @@ -802,6 +808,7 @@ export function useListNavigation(
virtual,
anchorElement,
nestedReturnFocusRef,
closeOnTab,
]);

const trigger: ElementProps['trigger'] = React.useMemo(() => {
Expand Down
Loading