From aca756abcc3125e23ac829d46b6298c934f9d4c1 Mon Sep 17 00:00:00 2001 From: Ahmad Wahaj Date: Thu, 19 Mar 2026 04:23:34 +0500 Subject: [PATCH 1/3] fix: In DropdownMenu, allow Tab navigation to interactive elements within menu content --- .changeset/public-beans-trade.md | 5 ++ .../stories/dropdown-menu.stories.module.css | 28 ++++++ .../stories/dropdown-menu.stories.tsx | 37 ++++++++ .../dropdown-menu/src/dropdown-menu.test.tsx | 87 +++++++++++++++++++ packages/react/menu/src/menu.tsx | 48 +++++++++- 5 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 .changeset/public-beans-trade.md create mode 100644 packages/react/dropdown-menu/src/dropdown-menu.test.tsx diff --git a/.changeset/public-beans-trade.md b/.changeset/public-beans-trade.md new file mode 100644 index 0000000000..ee57f5affa --- /dev/null +++ b/.changeset/public-beans-trade.md @@ -0,0 +1,5 @@ +--- +'@radix-ui/react-menu': major +--- + +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']` From 1015abcd40ccd33470755db9ac3b5c9f8053962a Mon Sep 17 00:00:00 2001 From: Ahmad Wahaj Date: Thu, 19 Mar 2026 15:59:00 +0500 Subject: [PATCH 2/3] fix: In DropdownMenu, allow Tab navigation to interactive elements within menu content --- .changeset/tired-sides-dance.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tired-sides-dance.md 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 From 2d3d6e335b7f5fc6629665413a148e481176176e Mon Sep 17 00:00:00 2001 From: Ahmad Wahaj Date: Thu, 19 Mar 2026 16:26:14 +0500 Subject: [PATCH 3/3] chore: add patch changeset for dropdown- menu and change major changeset to patch for react menu --- .changeset/public-beans-trade.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/public-beans-trade.md diff --git a/.changeset/public-beans-trade.md b/.changeset/public-beans-trade.md deleted file mode 100644 index ee57f5affa..0000000000 --- a/.changeset/public-beans-trade.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@radix-ui/react-menu': major ---- - -Allow Tab key navigation to interactive elements within menu content instead of always closing