fix(desktop): portal dropdown submenus into the parent menu's container

A submenu always portaled to document.body at z-50, even when its parent
menu lived inside a dialog. In the Kanban task dialog the model catalog's
thinking-effort submenu landed under the modal overlay: blurred and not
clickable.

SubContent now reads the parent Content's resolved portal container from
context (dialog content, or an explicit portalContainer). When it has one
it steps up to z-(--z-modal-popover) and restores pointer-events, which
the modal parent menu sets to none for a submenu that registers first.
Outside a dialog nothing changes.

Co-authored-by: Chen Jin <Enough1122@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-24 18:58:41 -05:00
committed by brooklyn!
parent 4050c91580
commit 8a0371f0f6
2 changed files with 150 additions and 21 deletions

View File

@@ -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 (
<DropdownMenu open>
<DropdownMenuContent portalContainer={portalContainer}>
<DropdownMenuSub open>
<DropdownMenuSubTrigger>Model</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem>High</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
)
}
it('keeps a submenu on the body portal, at z-50, outside a dialog', () => {
render(<OpenSubmenu />)
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(
<Dialog open>
<DialogContent>
<OpenSubmenu />
</DialogContent>
</Dialog>
)
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<HTMLDivElement | null>(null)
return (
<>
<div ref={setHost} />
{host ? <OpenSubmenu portalContainer={host} /> : null}
</>
)
}
const { container } = render(<Hosted />)
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 (
<Dialog open>
<DialogContent>
<DropdownMenu onOpenChange={onMenuOpenChange} open>
<DropdownMenuContent>
<DropdownMenuSub open={subOpen}>
<DropdownMenuSubTrigger>Model</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem onSelect={onSelect}>High</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuSub>
</DropdownMenuContent>
</DropdownMenu>
</DialogContent>
</Dialog>
)
}
// Open the submenu after the parent menu, as a hover would.
const { rerender } = render(<EffortMenu subOpen={false} />)
rerender(<EffortMenu subOpen />)
// 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)
})
})

View File

@@ -158,6 +158,9 @@ function createHoverSubmenus() {
const HoverSubmenusContext = React.createContext<ReturnType<typeof createHoverSubmenus> | 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<HTMLElement | undefined>(undefined)
function useRowSearchHover(props: RowPointerHandlers) {
const hoverSubmenus = React.useContext(HoverSubmenusContext)
@@ -246,22 +249,24 @@ function DropdownMenuContent({
return (
<DropdownMenuPrimitive.Portal container={container}>
<HoverSubmenusContext.Provider value={hoverSubmenus}>
<DropdownMenuPrimitive.Content
className={cn(
menuSurfaceClass,
menuMotionClass,
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
className
)}
// Keep the menu inside the viewport: Radix flips/shifts away from edges
// (avoidCollisions defaults on); the padding stops it kissing the edge.
collisionPadding={collisionPadding}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</HoverSubmenusContext.Provider>
<MenuPortalContainerContext.Provider value={container}>
<HoverSubmenusContext.Provider value={hoverSubmenus}>
<DropdownMenuPrimitive.Content
className={cn(
menuSurfaceClass,
menuMotionClass,
'z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto',
className
)}
// Keep the menu inside the viewport: Radix flips/shifts away from edges
// (avoidCollisions defaults on); the padding stops it kissing the edge.
collisionPadding={collisionPadding}
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
{...props}
/>
</HoverSubmenusContext.Provider>
</MenuPortalContainerContext.Provider>
</DropdownMenuPrimitive.Portal>
)
}
@@ -469,16 +474,20 @@ function DropdownMenuSubContent({
className,
collisionPadding = 8,
onPointerMove,
style,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
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.
<DropdownMenuPrimitive.Portal>
// `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.
<DropdownMenuPrimitive.Portal container={container}>
<DropdownMenuPrimitive.SubContent
// Fixed `max-h-80` rather than the Radix available-height variable:
// that variable is only published on Content, NOT SubContent — using
@@ -486,7 +495,10 @@ function DropdownMenuSubContent({
className={cn(
menuSurfaceClass,
menuMotionClass,
'z-50 max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto',
// Sharing the parent's container means sharing its stacking context,
// so step above the parent's z-50.
container ? 'z-(--z-modal-popover)' : 'z-50',
'max-h-80 min-w-36 origin-(--radix-dropdown-menu-content-transform-origin) overflow-y-auto',
className
)}
// Flip to the other side / shift vertically when near a viewport edge
@@ -499,6 +511,10 @@ function DropdownMenuSubContent({
hoverSubmenus?.keep()
onPointerMove?.(event)
}}
// The modal parent menu disables pointer events on dismissable layers
// registered before it. A submenu that mounts with the menu registers
// first (child effects run first), so Radix marks it `none`.
style={{ ...(container ? { pointerEvents: 'auto' } : null), ...style }}
{...props}
/>
</DropdownMenuPrimitive.Portal>