diff --git a/.changeset/tired-sides-dance.md b/.changeset/tired-sides-dance.md
new file mode 100644
index 0000000000..60cd333ce9
--- /dev/null
+++ b/.changeset/tired-sides-dance.md
@@ -0,0 +1,6 @@
+---
+'@radix-ui/react-dropdown-menu': patch
+'@radix-ui/react-menu': patch
+---
+
+Allow Tab key navigation to interactive elements within menu content instead of always closing
diff --git a/apps/storybook/stories/dropdown-menu.stories.module.css b/apps/storybook/stories/dropdown-menu.stories.module.css
index 3b342bb2f7..edf351708a 100644
--- a/apps/storybook/stories/dropdown-menu.stories.module.css
+++ b/apps/storybook/stories/dropdown-menu.stories.module.css
@@ -149,6 +149,34 @@
border: 1px solid rgb(0 0 0 / 0.3);
}
+.footer {
+ display: flex;
+ gap: 5px;
+ padding: 5px 10px;
+ border-top: 1px solid var(--color-gray100);
+ margin-top: 5px;
+}
+
+.footerButton {
+ flex: 1;
+ padding: 4px 8px;
+ border: 1px solid var(--color-gray100);
+ border-radius: 4px;
+ background-color: var(--color-white);
+ cursor: pointer;
+ font-size: 12px;
+ font-family: apple-system, BlinkMacSystemFont, helvetica, arial, sans-serif;
+
+ &:hover {
+ background-color: var(--color-gray100);
+ }
+
+ &:focus {
+ outline: none;
+ box-shadow: 0 0 0 2px rgb(0 0 0 / 0.5);
+ }
+}
+
.chromaticArrow {
fill: var(--color-black);
}
diff --git a/apps/storybook/stories/dropdown-menu.stories.tsx b/apps/storybook/stories/dropdown-menu.stories.tsx
index f409395009..9334a84cfc 100644
--- a/apps/storybook/stories/dropdown-menu.stories.tsx
+++ b/apps/storybook/stories/dropdown-menu.stories.tsx
@@ -1637,3 +1637,40 @@ const TickIcon = () => (
);
+
+export const WithFooterActions = () => (
+
+
+ Open
+
+
+ console.log('cut')}>
+ Cut
+
+ console.log('copy')}>
+ Copy
+
+ console.log('paste')}>
+ Paste
+
+
+ console.log('delete')}>
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+);
+
diff --git a/packages/react/dropdown-menu/src/dropdown-menu.test.tsx b/packages/react/dropdown-menu/src/dropdown-menu.test.tsx
new file mode 100644
index 0000000000..6e78cb57d1
--- /dev/null
+++ b/packages/react/dropdown-menu/src/dropdown-menu.test.tsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import type { RenderResult } from '@testing-library/react';
+import { cleanup, render, fireEvent, waitFor } from '@testing-library/react';
+import * as DropdownMenu from './dropdown-menu';
+import { afterEach, describe, it, beforeEach, vi, expect } from 'vitest';
+
+const TRIGGER_TEXT = 'Open';
+const APPLY_TEXT = 'Apply';
+const CREATE_TEXT = 'Create New';
+
+const DropdownMenuWithFooterTest = (props: React.ComponentProps) => (
+
+ {TRIGGER_TEXT}
+
+
+ Cut
+ Copy
+ Paste
+
+
+
+
+
+
+
+);
+
+describe('given an open DropdownMenu with footer actions', () => {
+ let rendered: RenderResult;
+ const onOpenChange = vi.fn();
+
+ afterEach(() => {
+ cleanup();
+ onOpenChange.mockClear();
+ });
+
+ beforeEach(async () => {
+ rendered = render(
+ ,
+ );
+ await waitFor(() => expect(rendered.getByText('Cut')).toBeVisible());
+ });
+
+ describe('when pressing Tab at the last tabbable element', () => {
+ beforeEach(() => {
+ const createButton = rendered.getByText(CREATE_TEXT);
+ createButton.focus();
+ fireEvent.keyDown(createButton, { key: 'Tab' });
+ });
+
+ it('should close the menu', async () => {
+ await waitFor(() => {
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+ });
+
+ describe('when pressing Shift+Tab at the first tabbable element', () => {
+ beforeEach(() => {
+ const firstItem = rendered.getByText('Cut');
+ firstItem.focus();
+ fireEvent.keyDown(firstItem, { key: 'Tab', shiftKey: true });
+ });
+
+ it('should close the menu', async () => {
+ await waitFor(() => {
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+ });
+ });
+
+ describe('when pressing Tab on a middle tabbable element', () => {
+ beforeEach(() => {
+ const applyButton = rendered.getByText(APPLY_TEXT);
+ applyButton.focus();
+ fireEvent.keyDown(applyButton, { key: 'Tab' });
+ });
+
+ it('should not close the menu', () => {
+ expect(onOpenChange).not.toHaveBeenCalledWith(false);
+ });
+
+ it('should keep the content visible', () => {
+ expect(rendered.getByText('Cut')).toBeVisible();
+ });
+ });
+});
diff --git a/packages/react/menu/src/menu.tsx b/packages/react/menu/src/menu.tsx
index 6840842ac5..af9d5b8009 100644
--- a/packages/react/menu/src/menu.tsx
+++ b/packages/react/menu/src/menu.tsx
@@ -514,8 +514,34 @@ const MenuContentImpl = React.forwardRef= tabbables.length - 1);
+ if (isAtEnd) {
+ event.preventDefault();
+ rootContext.onClose();
+ }
+ } else {
+ // Shift+Tab: close menu if at the first tabbable element
+ const isAtStart = currentIndex <= 0;
+ if (isAtStart) {
+ event.preventDefault();
+ rootContext.onClose();
+ }
+ }
+ }
+ }
if (!isModifierKey && isCharacterKey) handleTypeaheadSearch(event.key);
}
// focus first/last item based on key pressed
@@ -1247,6 +1273,24 @@ function focusFirst(candidates: HTMLElement[]) {
}
}
+/**
+ * Returns a list of tabbable candidates within a container.
+ * Tabbable elements are those with tabIndex >= 0 that are not disabled or hidden.
+ */
+function getTabbableCandidates(container: HTMLElement) {
+ const nodes: HTMLElement[] = [];
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
+ acceptNode: (node: any) => {
+ const isHiddenInput = node.tagName === 'INPUT' && node.type === 'hidden';
+ if (node.disabled || node.hidden || isHiddenInput) return NodeFilter.FILTER_SKIP;
+ return node.tabIndex >= 0 ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
+ },
+ });
+ while (walker.nextNode()) nodes.push(walker.currentNode as HTMLElement);
+ return nodes;
+}
+
+
/**
* Wraps an array around itself at a given start index
* Example: `wrapArray(['a', 'b', 'c', 'd'], 2) === ['c', 'd', 'a', 'b']`