Skip to content

Commit 9c7b243

Browse files
authored
fix(sidebar): keep the right-click context menu open over the collapsed chat flyout (#6665)
* fix(sidebar): keep the right-click context menu open over the collapsed chat flyout * fix(sidebar): use an absolute import in the context menu test * test(sidebar): cover item selection after a surrounding menu takes focus * fix(sidebar): keep the collapsed workflow actions menu open on right-click
1 parent cc7f005 commit 9c7b243

3 files changed

Lines changed: 172 additions & 0 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/collapsed-sidebar-menu/collapsed-sidebar-menu.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,22 @@ interface CollapsedWorkflowFlyoutItemProps {
166166
canRename?: boolean
167167
}
168168

169+
/**
170+
* Suppresses the Radix menu row's own pointer handlers, which focus the row on
171+
* `pointermove` and hand focus back to the flyout content on `pointerleave`.
172+
* A submenu closes on any focus that is not its trigger, so while this row's
173+
* actions submenu is open those two handlers would close it the instant the
174+
* cursor moved — the path a right-click takes, since it opens the submenu with
175+
* the cursor still over the row rather than over the trigger. Radix composes
176+
* consumer handlers ahead of its own and skips its own once the event is
177+
* defaulted, so preventing default here holds focus still until the cursor
178+
* reaches the submenu. Only applied to the row whose submenu is open: moving on
179+
* to any other row still steals focus and closes it, as it should.
180+
*/
181+
const holdRowFocus = (e: React.PointerEvent) => {
182+
if (e.pointerType === 'mouse') e.preventDefault()
183+
}
184+
169185
const EDIT_ROW_CLASS = cn(
170186
chipVariants({ active: true, fullWidth: true }),
171187
'min-w-0 cursor-default select-none text-small'
@@ -340,6 +356,8 @@ export function CollapsedWorkflowFlyoutItem({
340356
<DropdownMenuItem
341357
asChild
342358
active={isCurrentRoute || actionsOpen}
359+
onPointerMove={actionsOpen ? holdRowFocus : undefined}
360+
onPointerLeave={actionsOpen ? holdRowFocus : undefined}
343361
action={
344362
hasActions ? (
345363
<DropdownMenuSub
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*
4+
* The sidebar context menu is opened by right-clicking a row, including rows that live
5+
* inside another Radix menu — the collapsed sidebar's chat flyout. Radix menu rows focus
6+
* themselves on `pointermove` and a menu refocuses its own content when the pointer leaves
7+
* a row, so the first mouse movement after the right-click moves focus into the flyout.
8+
* A non-modal Radix menu dismisses on focus-outside, which closed this menu before the
9+
* cursor could reach it. These tests pin which dismissal paths survive: focus landing in a
10+
* surrounding menu keeps it open, focus landing anywhere else and a press outside close it.
11+
*/
12+
import { act } from 'react'
13+
import { sleep } from '@sim/utils/helpers'
14+
import { createRoot, type Root } from 'react-dom/client'
15+
import { afterEach, describe, expect, it, vi } from 'vitest'
16+
import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu'
17+
18+
let root: Root | null = null
19+
let container: HTMLDivElement | null = null
20+
let plainButton: HTMLButtonElement | null = null
21+
let surroundingMenuItem: HTMLDivElement | null = null
22+
23+
/**
24+
* Stands in for the collapsed sidebar's chat flyout: a Radix menu whose rows this menu
25+
* is drawn on top of, and which steals focus back as soon as the pointer moves.
26+
*/
27+
function mountSurroundingMenu() {
28+
const menu = document.createElement('div')
29+
menu.setAttribute('role', 'menu')
30+
const item = document.createElement('div')
31+
item.setAttribute('role', 'menuitem')
32+
item.tabIndex = -1
33+
menu.appendChild(item)
34+
document.body.appendChild(menu)
35+
surroundingMenuItem = item
36+
}
37+
38+
function renderMenu(onClose: () => void, onDelete: () => void = () => {}) {
39+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
40+
mountSurroundingMenu()
41+
plainButton = document.createElement('button')
42+
document.body.appendChild(plainButton)
43+
container = document.createElement('div')
44+
document.body.appendChild(container)
45+
root = createRoot(container)
46+
act(() =>
47+
root?.render(
48+
<ContextMenu
49+
isOpen
50+
position={{ x: 10, y: 10 }}
51+
menuRef={{ current: null }}
52+
onClose={onClose}
53+
onDelete={onDelete}
54+
showRename={false}
55+
showDuplicate={false}
56+
/>
57+
)
58+
)
59+
}
60+
61+
function contextMenuIsOpen() {
62+
return Array.from(document.querySelectorAll('[role="menuitem"]')).some(
63+
(item) => item.textContent === 'Delete'
64+
)
65+
}
66+
67+
function focusOutside(element: HTMLElement | null) {
68+
act(() => {
69+
element?.focus()
70+
element?.dispatchEvent(new FocusEvent('focusin', { bubbles: true }))
71+
})
72+
}
73+
74+
afterEach(() => {
75+
if (root) act(() => root?.unmount())
76+
container?.remove()
77+
plainButton?.remove()
78+
surroundingMenuItem?.closest('[role="menu"]')?.remove()
79+
root = null
80+
container = null
81+
plainButton = null
82+
surroundingMenuItem = null
83+
})
84+
85+
describe('sidebar context menu dismissal', () => {
86+
it('stays open when a surrounding menu takes focus back', () => {
87+
const onClose = vi.fn()
88+
renderMenu(onClose)
89+
expect(contextMenuIsOpen()).toBe(true)
90+
91+
focusOutside(surroundingMenuItem)
92+
93+
expect(onClose).not.toHaveBeenCalled()
94+
expect(contextMenuIsOpen()).toBe(true)
95+
})
96+
97+
it('still runs an item action after a surrounding menu took focus', () => {
98+
const onClose = vi.fn()
99+
const onDelete = vi.fn()
100+
renderMenu(onClose, onDelete)
101+
102+
focusOutside(surroundingMenuItem)
103+
act(() => {
104+
const deleteItem = Array.from(document.querySelectorAll('[role="menuitem"]')).find(
105+
(item) => item.textContent === 'Delete'
106+
)
107+
;(deleteItem as HTMLElement | undefined)?.click()
108+
})
109+
110+
expect(onDelete).toHaveBeenCalled()
111+
expect(onClose).toHaveBeenCalled()
112+
})
113+
114+
it('closes when focus leaves for an element outside any menu', () => {
115+
const onClose = vi.fn()
116+
renderMenu(onClose)
117+
118+
focusOutside(plainButton)
119+
120+
expect(onClose).toHaveBeenCalled()
121+
})
122+
123+
it('closes on a pointer press outside it', async () => {
124+
const onClose = vi.fn()
125+
renderMenu(onClose)
126+
127+
await act(async () => {
128+
await sleep(1)
129+
})
130+
131+
await act(async () => {
132+
plainButton?.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true }))
133+
plainButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }))
134+
await sleep(1)
135+
})
136+
137+
expect(onClose).toHaveBeenCalled()
138+
})
139+
})

apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,15 @@ interface ContextMenuProps {
103103
/**
104104
* Context menu component for workflow, folder, and workspace items.
105105
* Uses DropdownMenu for accessible, hover-expandable submenus.
106+
*
107+
* A non-modal Radix menu dismisses itself whenever focus lands outside it, and this
108+
* menu is routinely opened on top of another Radix menu — the collapsed sidebar's
109+
* chat flyout. Radix menu rows call `focus()` on `pointermove` and a menu refocuses
110+
* its own content when the pointer leaves a row, so the first mouse movement after a
111+
* right-click inside the flyout pulled focus back into the flyout and closed this
112+
* menu before the cursor could reach it. `onFocusOutside` therefore ignores focus
113+
* that lands in a surrounding menu; focus leaving to anything else (tabbing away)
114+
* still dismisses, as do pointer-down outside, Escape, and selecting an item.
106115
*/
107116
export function ContextMenu({
108117
isOpen,
@@ -200,6 +209,12 @@ export function ContextMenu({
200209
side='bottom'
201210
sideOffset={4}
202211
className='max-h-[var(--radix-dropdown-menu-content-available-height,400px)]'
212+
onFocusOutside={(e) => {
213+
const target = e.target
214+
if (target instanceof Element && target.closest('[role="menu"]')) {
215+
e.preventDefault()
216+
}
217+
}}
203218
onCloseAutoFocus={(e) => {
204219
e.preventDefault()
205220
const shouldFocusRenameInput = justSelectedRenameRef.current

0 commit comments

Comments
 (0)