From bacc5c32f56bd48f887e844cd6949a9ee25aaf3b Mon Sep 17 00:00:00 2001 From: yyh Date: Thu, 29 Jan 2026 14:01:36 +0800 Subject: [PATCH] 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. --- .../use-context-menu-floating.spec.tsx | 183 ++++++++++++++++++ .../use-context-menu-floating.ts | 92 +++++++++ .../skill/file-tree/tree-context-menu.tsx | 55 ++++-- 3 files changed, 309 insertions(+), 21 deletions(-) create mode 100644 web/app/components/base/portal-to-follow-elem/use-context-menu-floating.spec.tsx create mode 100644 web/app/components/base/portal-to-follow-elem/use-context-menu-floating.ts diff --git a/web/app/components/base/portal-to-follow-elem/use-context-menu-floating.spec.tsx b/web/app/components/base/portal-to-follow-elem/use-context-menu-floating.spec.tsx new file mode 100644 index 0000000000..af71d96b78 --- /dev/null +++ b/web/app/components/base/portal-to-follow-elem/use-context-menu-floating.spec.tsx @@ -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[0]['placement'] + offset?: Parameters[0]['offset'] +}) { + const { refs, floatingStyles, getFloatingProps, isPositioned } = useContextMenuFloating({ + open, + onOpenChange, + position, + placement, + offset: offsetValue, + }) + + if (!open) + return null + + return ( + +
+ +
+
+ ) +} + +describe('useContextMenuFloating', () => { + it('should render menu when open', () => { + render( + , + ) + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) + + it('should not render menu when closed', () => { + render( + , + ) + + expect(screen.queryByTestId('context-menu')).not.toBeInTheDocument() + }) + + it('should apply ARIA role="menu" to floating element', () => { + render( + , + ) + + const menu = screen.getByTestId('context-menu') + expect(menu).toHaveAttribute('role', 'menu') + }) + + it('should call onOpenChange(false) on Escape key', () => { + const handleOpenChange = vi.fn() + + render( + , + ) + + 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( +
+
Outside
+ +
, + ) + + fireEvent.pointerDown(screen.getByTestId('outside')) + expect(handleOpenChange).toHaveBeenCalled() + expect(handleOpenChange.mock.calls[0][0]).toBe(false) + }) + + it('should accept custom placement', () => { + render( + , + ) + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) + + it('should accept custom offset', () => { + render( + , + ) + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) + + it('should accept offset as object', () => { + render( + , + ) + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) + + it('should update position when coordinates change', () => { + const { rerender } = render( + , + ) + + const menu = screen.getByTestId('context-menu') + expect(menu).toBeInTheDocument() + + rerender( + , + ) + + expect(screen.getByTestId('context-menu')).toBeInTheDocument() + }) +}) diff --git a/web/app/components/base/portal-to-follow-elem/use-context-menu-floating.ts b/web/app/components/base/portal-to-follow-elem/use-context-menu-floating.ts new file mode 100644 index 0000000000..870a59acd2 --- /dev/null +++ b/web/app/components/base/portal-to-follow-elem/use-context-menu-floating.ts @@ -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], + ) +} diff --git a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx index 3fb49c763b..0de4200921 100644 --- a/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx +++ b/web/app/components/workflow/skill/file-tree/tree-context-menu.tsx @@ -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(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 ( -
- -
+ +
+ +
+
) }