diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx index 814535edf1b..9935ab0c8eb 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx @@ -166,6 +166,22 @@ interface CollapsedWorkflowFlyoutItemProps { canRename?: boolean } +/** + * Suppresses the Radix menu row's own pointer handlers, which focus the row on + * `pointermove` and hand focus back to the flyout content on `pointerleave`. + * A submenu closes on any focus that is not its trigger, so while this row's + * actions submenu is open those two handlers would close it the instant the + * cursor moved — the path a right-click takes, since it opens the submenu with + * the cursor still over the row rather than over the trigger. Radix composes + * consumer handlers ahead of its own and skips its own once the event is + * defaulted, so preventing default here holds focus still until the cursor + * reaches the submenu. Only applied to the row whose submenu is open: moving on + * to any other row still steals focus and closes it, as it should. + */ +const holdRowFocus = (e: React.PointerEvent) => { + if (e.pointerType === 'mouse') e.preventDefault() +} + const EDIT_ROW_CLASS = cn( chipVariants({ active: true, fullWidth: true }), 'min-w-0 cursor-default select-none text-small' @@ -340,6 +356,8 @@ export function CollapsedWorkflowFlyoutItem({ void, onDelete: () => void = () => {}) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + mountSurroundingMenu() + plainButton = document.createElement('button') + document.body.appendChild(plainButton) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => + root?.render( + + ) + ) +} + +function contextMenuIsOpen() { + return Array.from(document.querySelectorAll('[role="menuitem"]')).some( + (item) => item.textContent === 'Delete' + ) +} + +function focusOutside(element: HTMLElement | null) { + act(() => { + element?.focus() + element?.dispatchEvent(new FocusEvent('focusin', { bubbles: true })) + }) +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + plainButton?.remove() + surroundingMenuItem?.closest('[role="menu"]')?.remove() + root = null + container = null + plainButton = null + surroundingMenuItem = null +}) + +describe('sidebar context menu dismissal', () => { + it('stays open when a surrounding menu takes focus back', () => { + const onClose = vi.fn() + renderMenu(onClose) + expect(contextMenuIsOpen()).toBe(true) + + focusOutside(surroundingMenuItem) + + expect(onClose).not.toHaveBeenCalled() + expect(contextMenuIsOpen()).toBe(true) + }) + + it('still runs an item action after a surrounding menu took focus', () => { + const onClose = vi.fn() + const onDelete = vi.fn() + renderMenu(onClose, onDelete) + + focusOutside(surroundingMenuItem) + act(() => { + const deleteItem = Array.from(document.querySelectorAll('[role="menuitem"]')).find( + (item) => item.textContent === 'Delete' + ) + ;(deleteItem as HTMLElement | undefined)?.click() + }) + + expect(onDelete).toHaveBeenCalled() + expect(onClose).toHaveBeenCalled() + }) + + it('closes when focus leaves for an element outside any menu', () => { + const onClose = vi.fn() + renderMenu(onClose) + + focusOutside(plainButton) + + expect(onClose).toHaveBeenCalled() + }) + + it('closes on a pointer press outside it', async () => { + const onClose = vi.fn() + renderMenu(onClose) + + await act(async () => { + await sleep(1) + }) + + await act(async () => { + plainButton?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })) + plainButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await sleep(1) + }) + + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index f381806cea1..8be1feef7f3 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -103,6 +103,15 @@ interface ContextMenuProps { /** * Context menu component for workflow, folder, and workspace items. * Uses DropdownMenu for accessible, hover-expandable submenus. + * + * A non-modal Radix menu dismisses itself whenever focus lands outside it, and this + * menu is routinely opened on top of another Radix menu — the collapsed sidebar's + * chat flyout. Radix menu rows call `focus()` on `pointermove` and a menu refocuses + * its own content when the pointer leaves a row, so the first mouse movement after a + * right-click inside the flyout pulled focus back into the flyout and closed this + * menu before the cursor could reach it. `onFocusOutside` therefore ignores focus + * that lands in a surrounding menu; focus leaving to anything else (tabbing away) + * still dismisses, as do pointer-down outside, Escape, and selecting an item. */ export function ContextMenu({ isOpen, @@ -200,6 +209,12 @@ export function ContextMenu({ side='bottom' sideOffset={4} className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]' + onFocusOutside={(e) => { + const target = e.target + if (target instanceof Element && target.closest('[role="menu"]')) { + e.preventDefault() + } + }} onCloseAutoFocus={(e) => { e.preventDefault() const shouldFocusRenameInput = justSelectedRenameRef.current