diff --git a/apps/desktop/src/components/ui/dropdown-menu.test.tsx b/apps/desktop/src/components/ui/dropdown-menu.test.tsx
index 561f22279c..fb02106f1a 100644
--- a/apps/desktop/src/components/ui/dropdown-menu.test.tsx
+++ b/apps/desktop/src/components/ui/dropdown-menu.test.tsx
@@ -1,6 +1,8 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
+import { useState } from 'react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
+import { Dialog, DialogContent } from './dialog'
import {
DropdownMenu,
DropdownMenuContent,
@@ -132,3 +134,114 @@ describe('DropdownMenuSearch hover focus', () => {
expect(row.ownerDocument.activeElement).toBe(row)
})
})
+
+describe('DropdownMenuSubContent portal', () => {
+ function OpenSubmenu({ portalContainer }: { portalContainer?: HTMLElement | null }) {
+ return (
+
+
+
+ Model
+
+ High
+
+
+
+
+ )
+ }
+
+ it('keeps a submenu on the body portal, at z-50, outside a dialog', () => {
+ render()
+
+ const sub = screen.getByText('High').closest('[data-slot="dropdown-menu-sub-content"]')
+
+ expect(sub).not.toBeNull()
+ expect(sub?.closest('[data-slot="dialog-content"]')).toBeNull()
+ expect(sub?.ownerDocument.body.contains(sub)).toBe(true)
+ expect(sub?.className).toContain('z-50')
+ expect(sub?.className).not.toContain('z-(--z-modal-popover)')
+ })
+
+ it('portals a submenu into the dialog content and raises it above the parent menu', () => {
+ render(
+
+ )
+
+ const sub = screen.getByText('High').closest('[data-slot="dropdown-menu-sub-content"]')
+ const dialog = screen.getByRole('dialog')
+ const menu = screen.getByRole('menuitem', { name: 'Model' }).closest('[data-slot="dropdown-menu-content"]')
+
+ expect(dialog.contains(sub)).toBe(true)
+ expect(dialog.contains(menu)).toBe(true)
+ expect(sub?.className).toContain('z-(--z-modal-popover)')
+ expect((sub as HTMLElement).style.pointerEvents).toBe('auto')
+ })
+
+ it('uses the same explicit portal container as the parent menu', () => {
+ function Hosted() {
+ const [host, setHost] = useState(null)
+
+ return (
+ <>
+
+ {host ? : null}
+ >
+ )
+ }
+
+ const { container } = render()
+
+ const sub = screen.getByText('High').closest('[data-slot="dropdown-menu-sub-content"]')
+ const menu = screen.getByRole('menuitem', { name: 'Model' }).closest('[data-slot="dropdown-menu-content"]')
+ const host = container.firstElementChild
+
+ expect(host?.contains(sub)).toBe(true)
+ expect(host?.contains(menu)).toBe(true)
+ expect(sub?.className).toContain('z-(--z-modal-popover)')
+ })
+
+ it('selects a submenu row inside a dialog without dismissing the parent menu', async () => {
+ const onMenuOpenChange = vi.fn()
+ const onSelect = vi.fn((event: Event) => event.preventDefault())
+
+ function EffortMenu({ subOpen }: { subOpen: boolean }) {
+ return (
+
+ )
+ }
+
+ // Open the submenu after the parent menu, as a hover would.
+ const { rerender } = render()
+ rerender()
+
+ // DismissableLayer registers its document pointerdown listener in a
+ // setTimeout(0); flush it so an outside press would actually dismiss.
+ await act(() => new Promise(resolve => setTimeout(resolve, 10)))
+
+ const row = screen.getByRole('menuitem', { name: 'High' })
+ fireEvent.pointerDown(row, { button: 0, pointerType: 'mouse' })
+ fireEvent.pointerUp(row, { button: 0, pointerType: 'mouse' })
+ fireEvent.click(row)
+
+ expect(onSelect).toHaveBeenCalledTimes(1)
+ expect(onMenuOpenChange).not.toHaveBeenCalledWith(false)
+ })
+})
diff --git a/apps/desktop/src/components/ui/dropdown-menu.tsx b/apps/desktop/src/components/ui/dropdown-menu.tsx
index 5a63fdecc7..85215c852b 100644
--- a/apps/desktop/src/components/ui/dropdown-menu.tsx
+++ b/apps/desktop/src/components/ui/dropdown-menu.tsx
@@ -158,6 +158,9 @@ function createHoverSubmenus() {
const HoverSubmenusContext = React.createContext | null>(null)
const SubOpenContext = React.createContext<{ open: boolean; setOpen: SetSubOpen } | null>(null)
+// The parent Content's resolved portal target (dialog content or an explicit
+// portalContainer), so its submenus portal into the same node.
+const MenuPortalContainerContext = React.createContext(undefined)
function useRowSearchHover(props: RowPointerHandlers) {
const hoverSubmenus = React.useContext(HoverSubmenusContext)
@@ -246,22 +249,24 @@ function DropdownMenuContent({
return (
-
-
-
+
+
+
+
+
)
}
@@ -469,16 +474,20 @@ function DropdownMenuSubContent({
className,
collisionPadding = 8,
onPointerMove,
+ style,
...props
}: React.ComponentProps) {
const hoverSubmenus = React.useContext(HoverSubmenusContext)
+ // A body portal would sit under a dialog's modal overlay: blurred, and the
+ // overlay eats its clicks (#80798).
+ const container = React.useContext(MenuPortalContainerContext)
return (
// Portal the submenu out of the parent Content so it escapes that Content's
- // `overflow` clip. Without this, a submenu opening from a scrollable menu
- // gets visually cut off at the parent's edges. Radix Popper still anchors
- // it to the SubTrigger and handles collision/flip, so portaling is safe.
-
+ // `overflow` clip. Radix Popper still anchors it to the SubTrigger and
+ // handles collision/flip. React events still bubble through the portal, so
+ // the parent menu doesn't treat a press here as an outside click.
+