feat(portal): add useContextMenuFloating hook for coordinate-based context menus

Replace useClickAway + fixed positioning in file tree context menu with
a floating-ui based hook that provides collision detection (flip/shift),
ARIA role="menu", Escape/outside-click dismiss, and scroll dismiss via
passive capture listener with ref-stabilized callback.
This commit is contained in:
yyh
2026-01-29 14:01:36 +08:00
parent efb3657cfe
commit bacc5c32f5
3 changed files with 309 additions and 21 deletions

View File

@ -0,0 +1,183 @@
import { FloatingPortal } from '@floating-ui/react'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { useContextMenuFloating } from './use-context-menu-floating'
afterEach(cleanup)
function TestContextMenu({
open,
onOpenChange,
position,
placement,
offset: offsetValue,
}: {
open: boolean
onOpenChange: (open: boolean) => void
position: { x: number, y: number }
placement?: Parameters<typeof useContextMenuFloating>[0]['placement']
offset?: Parameters<typeof useContextMenuFloating>[0]['offset']
}) {
const { refs, floatingStyles, getFloatingProps, isPositioned } = useContextMenuFloating({
open,
onOpenChange,
position,
placement,
offset: offsetValue,
})
if (!open)
return null
return (
<FloatingPortal>
<div
ref={refs.setFloating}
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
{...getFloatingProps()}
data-testid="context-menu"
>
<button onClick={() => onOpenChange(false)}>Action</button>
</div>
</FloatingPortal>
)
}
describe('useContextMenuFloating', () => {
it('should render menu when open', () => {
render(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
/>,
)
expect(screen.getByTestId('context-menu')).toBeInTheDocument()
})
it('should not render menu when closed', () => {
render(
<TestContextMenu
open={false}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
/>,
)
expect(screen.queryByTestId('context-menu')).not.toBeInTheDocument()
})
it('should apply ARIA role="menu" to floating element', () => {
render(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
/>,
)
const menu = screen.getByTestId('context-menu')
expect(menu).toHaveAttribute('role', 'menu')
})
it('should call onOpenChange(false) on Escape key', () => {
const handleOpenChange = vi.fn()
render(
<TestContextMenu
open={true}
onOpenChange={handleOpenChange}
position={{ x: 100, y: 200 }}
/>,
)
fireEvent.keyDown(document, { key: 'Escape' })
expect(handleOpenChange).toHaveBeenCalled()
expect(handleOpenChange.mock.calls[0][0]).toBe(false)
})
it('should call onOpenChange(false) on outside click', () => {
const handleOpenChange = vi.fn()
render(
<div>
<div data-testid="outside">Outside</div>
<TestContextMenu
open={true}
onOpenChange={handleOpenChange}
position={{ x: 100, y: 200 }}
/>
</div>,
)
fireEvent.pointerDown(screen.getByTestId('outside'))
expect(handleOpenChange).toHaveBeenCalled()
expect(handleOpenChange.mock.calls[0][0]).toBe(false)
})
it('should accept custom placement', () => {
render(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
placement="top-end"
/>,
)
expect(screen.getByTestId('context-menu')).toBeInTheDocument()
})
it('should accept custom offset', () => {
render(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
offset={8}
/>,
)
expect(screen.getByTestId('context-menu')).toBeInTheDocument()
})
it('should accept offset as object', () => {
render(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
offset={{ mainAxis: 4, crossAxis: 2 }}
/>,
)
expect(screen.getByTestId('context-menu')).toBeInTheDocument()
})
it('should update position when coordinates change', () => {
const { rerender } = render(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 100, y: 200 }}
/>,
)
const menu = screen.getByTestId('context-menu')
expect(menu).toBeInTheDocument()
rerender(
<TestContextMenu
open={true}
onOpenChange={vi.fn()}
position={{ x: 300, y: 400 }}
/>,
)
expect(screen.getByTestId('context-menu')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,92 @@
import type { OffsetOptions, Placement } from '@floating-ui/react'
import {
flip,
offset,
shift,
useDismiss,
useFloating,
useInteractions,
useRole,
} from '@floating-ui/react'
import { useEffect, useMemo, useRef } from 'react'
export type Position = {
x: number
y: number
}
export type UseContextMenuFloatingOptions = {
open: boolean
onOpenChange: (open: boolean) => void
position: Position
placement?: Placement
offset?: number | OffsetOptions
}
export function useContextMenuFloating({
open,
onOpenChange,
position,
placement = 'bottom-start',
offset: offsetValue = 0,
}: UseContextMenuFloatingOptions) {
const onOpenChangeRef = useRef(onOpenChange)
onOpenChangeRef.current = onOpenChange
const data = useFloating({
placement,
open,
onOpenChange,
middleware: [
offset(offsetValue),
flip({
crossAxis: placement.includes('-'),
fallbackAxisSideDirection: 'start',
padding: 5,
}),
shift({ padding: 5 }),
],
})
const { context, refs, floatingStyles, isPositioned } = data
useEffect(() => {
refs.setPositionReference({
getBoundingClientRect: () => ({
width: 0,
height: 0,
x: position.x,
y: position.y,
top: position.y,
left: position.x,
right: position.x,
bottom: position.y,
}),
})
}, [position.x, position.y, refs])
useEffect(() => {
if (!open)
return
const handler = () => onOpenChangeRef.current(false)
window.addEventListener('scroll', handler, { capture: true, passive: true })
return () => window.removeEventListener('scroll', handler, { capture: true })
}, [open])
const dismiss = useDismiss(context)
const role = useRole(context, { role: 'menu' })
const interactions = useInteractions([dismiss, role])
return useMemo(
() => ({
refs: {
setFloating: refs.setFloating,
},
floatingStyles,
getFloatingProps: interactions.getFloatingProps,
context,
isPositioned,
}),
[context, floatingStyles, isPositioned, refs.setFloating, interactions.getFloatingProps],
)
}

View File

@ -2,9 +2,10 @@
import type { TreeApi } from 'react-arborist'
import type { TreeNodeData } from '../type'
import { useClickAway } from 'ahooks'
import { FloatingPortal } from '@floating-ui/react'
import * as React from 'react'
import { useCallback, useRef } from 'react'
import { useCallback, useMemo } from 'react'
import { useContextMenuFloating } from '@/app/components/base/portal-to-follow-elem/use-context-menu-floating'
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
import { getMenuNodeId, getNodeMenuType } from '../utils/tree-utils'
import NodeMenu from './node-menu'
@ -14,7 +15,6 @@ type TreeContextMenuProps = {
}
const TreeContextMenu = ({ treeRef }: TreeContextMenuProps) => {
const ref = useRef<HTMLDivElement>(null)
const contextMenu = useStore(s => s.contextMenu)
const storeApi = useWorkflowStore()
@ -22,29 +22,42 @@ const TreeContextMenu = ({ treeRef }: TreeContextMenuProps) => {
storeApi.getState().setContextMenu(null)
}, [storeApi])
useClickAway(() => {
handleClose()
}, ref)
const position = useMemo(() => ({
x: contextMenu?.left ?? 0,
y: contextMenu?.top ?? 0,
}), [contextMenu?.left, contextMenu?.top])
const { refs, floatingStyles, getFloatingProps, isPositioned } = useContextMenuFloating({
open: !!contextMenu,
onOpenChange: (open) => {
if (!open)
handleClose()
},
position,
})
if (!contextMenu)
return null
return (
<div
ref={ref}
className="fixed z-[100]"
style={{
top: contextMenu.top,
left: contextMenu.left,
}}
>
<NodeMenu
type={getNodeMenuType(contextMenu.type, contextMenu.isFolder)}
nodeId={getMenuNodeId(contextMenu.type, contextMenu.nodeId)}
onClose={handleClose}
treeRef={treeRef}
/>
</div>
<FloatingPortal>
<div
ref={refs.setFloating}
className="z-[100]"
style={{
...floatingStyles,
visibility: isPositioned ? 'visible' : 'hidden',
}}
{...getFloatingProps()}
>
<NodeMenu
type={getNodeMenuType(contextMenu.type, contextMenu.isFolder)}
nodeId={getMenuNodeId(contextMenu.type, contextMenu.nodeId)}
onClose={handleClose}
treeRef={treeRef}
/>
</div>
</FloatingPortal>
)
}