mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 08:28:03 +08:00
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:
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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],
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user