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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user