From 8a0371f0f6dfdc58cdd7fbebf60d3ca90225267b Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Thu, 24 Sep 2026 18:58:41 -0500 Subject: [PATCH] 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 --- .../src/components/ui/dropdown-menu.test.tsx | 113 ++++++++++++++++++ .../src/components/ui/dropdown-menu.tsx | 58 +++++---- 2 files changed, 150 insertions(+), 21 deletions(-) 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 ( + + + + + + Model + + High + + + + + + + ) + } + + // 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. +