Skip to content
Open
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
6 changes: 6 additions & 0 deletions .changeset/tired-sides-dance.md
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions apps/storybook/stories/dropdown-menu.stories.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
37 changes: 37 additions & 0 deletions apps/storybook/stories/dropdown-menu.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1637,3 +1637,40 @@ const TickIcon = () => (
<path d="M2 20 L12 28 30 4" />
</svg>
);

export const WithFooterActions = () => (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '50vh' }}>
<DropdownMenu.Root>
<DropdownMenu.Trigger className={styles.trigger}>Open</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content className={styles.content} sideOffset={5}>
<DropdownMenu.Item className={styles.item} onSelect={() => console.log('cut')}>
Cut
</DropdownMenu.Item>
<DropdownMenu.Item className={styles.item} onSelect={() => console.log('copy')}>
Copy
</DropdownMenu.Item>
<DropdownMenu.Item className={styles.item} onSelect={() => console.log('paste')}>
Paste
</DropdownMenu.Item>
<DropdownMenu.Separator className={styles.separator} />
<DropdownMenu.Item className={styles.item} onSelect={() => console.log('delete')}>
Delete
</DropdownMenu.Item>

<div className={styles.footer}>
<button className={styles.footerButton} onClick={() => console.log('apply')}>
Apply
</button>
<button className={styles.footerButton} onClick={() => console.log('create new')}>
Create New
</button>
</div>

<DropdownMenu.Arrow />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
</div>
);

87 changes: 87 additions & 0 deletions packages/react/dropdown-menu/src/dropdown-menu.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DropdownMenu.Root>) => (
<DropdownMenu.Root {...props}>
<DropdownMenu.Trigger>{TRIGGER_TEXT}</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item>Cut</DropdownMenu.Item>
<DropdownMenu.Item>Copy</DropdownMenu.Item>
<DropdownMenu.Item>Paste</DropdownMenu.Item>
<div>
<button>{APPLY_TEXT}</button>
<button>{CREATE_TEXT}</button>
</div>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);

describe('given an open DropdownMenu with footer actions', () => {
let rendered: RenderResult;
const onOpenChange = vi.fn();

afterEach(() => {
cleanup();
onOpenChange.mockClear();
});

beforeEach(async () => {
rendered = render(
<DropdownMenuWithFooterTest defaultOpen modal={false} onOpenChange={onOpenChange} />,
);
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();
});
});
});
48 changes: 46 additions & 2 deletions packages/react/menu/src/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,8 +514,34 @@ const MenuContentImpl = React.forwardRef<MenuContentImplElement, MenuContentImpl
const isModifierKey = event.ctrlKey || event.altKey || event.metaKey;
const isCharacterKey = event.key.length === 1;
if (isKeyDownInside) {
// menus should not be navigated using tab key so we prevent it
if (event.key === 'Tab') event.preventDefault();
if (event.key === 'Tab') {
// Allow Tab to navigate to other interactive elements within the menu
// content (e.g. footer buttons). Close the menu when focus would leave.
const content = contentRef.current;
if (content) {
const tabbables = getTabbableCandidates(content);
const focusedElement = document.activeElement as HTMLElement;
const currentIndex = tabbables.indexOf(focusedElement);

if (!event.shiftKey) {
// Forward Tab: close menu if at the last tabbable element or no tabbables
const isAtEnd =
tabbables.length === 0 ||
(currentIndex !== -1 && currentIndex >= 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
Expand Down Expand Up @@ -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']`
Expand Down