diff --git a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx index b7248dcb90a..decb090ab25 100644 --- a/web/app/components/workflow/block-selector/__tests__/main.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/main.spec.tsx @@ -287,6 +287,39 @@ describe('NodeSelector', () => { expect(screen.getByPlaceholderText('workflow.tabs.searchBlock')).toBeInTheDocument() }) + it('isolates popup keyboard events when opened from another keyboard-managed overlay', async () => { + const user = userEvent.setup() + const handleParentKeyDown = vi.fn() + + renderNodeSelector( + , + ) + + const searchInput = screen.getByPlaceholderText('workflow.tabs.searchBlock') as HTMLInputElement + document.body.addEventListener('keydown', handleParentKeyDown) + + try { + await user.type(searchInput, 'LLM') + } + finally { + document.body.removeEventListener('keydown', handleParentKeyDown) + } + + expect(searchInput.value).toBe('LLM') + expect(handleParentKeyDown).not.toHaveBeenCalled() + expect(screen.getByText('LLM')).toBeInTheDocument() + expect(screen.queryByText('End')).not.toBeInTheDocument() + }) + it('disables the start tab with a setup tooltip when an unconfigured start node is on the canvas', async () => { const user = userEvent.setup() diff --git a/web/app/components/workflow/block-selector/main.tsx b/web/app/components/workflow/block-selector/main.tsx index 6678f081fe9..51f49d46b20 100644 --- a/web/app/components/workflow/block-selector/main.tsx +++ b/web/app/components/workflow/block-selector/main.tsx @@ -64,6 +64,7 @@ export type NodeSelectorProps = { forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type). allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist. snippetInsertPayload?: Parameters[1] + isolateKeyboardEvents?: boolean } function NodeSelector({ open: openFromProps, @@ -90,6 +91,7 @@ function NodeSelector({ forceEnableStartTab = false, allowUserInputSelection, snippetInsertPayload, + isolateKeyboardEvents = false, }: NodeSelectorProps) { const { t } = useTranslation() const nodes = useNodes() @@ -182,6 +184,10 @@ function NodeSelector({ if (open && newActiveTab === TabsEnum.Snippets) setSnippetsLoading(true) }, [open, setActiveTab]) + const handlePopupKeyDown = useCallback((event: React.KeyboardEvent) => { + if (isolateKeyboardEvents) + event.stopPropagation() + }, [isolateKeyboardEvents]) useEffect(() => { if (!snippetsLoading) @@ -263,6 +269,7 @@ function NodeSelector({ sideOffset={sideOffset} alignOffset={alignOffset} popupClassName="border-none bg-transparent shadow-none" + popupProps={isolateKeyboardEvents ? { onKeyDown: handlePopupKeyDown } : undefined} >
{ showStartTab: true, placement: 'right-start', popupClassName: 'min-w-[256px]!', + isolateKeyboardEvents: undefined, }) expect(latestBlockSelectorProps?.defaultActiveTab).toBeUndefined() expect(latestBlockSelectorProps?.offset).toEqual({ @@ -241,6 +243,14 @@ describe('AddBlock', () => { expect(latestBlockSelectorProps?.showStartTab).toBe(true) expect(latestBlockSelectorProps?.defaultActiveTab).toBeUndefined() }) + + it('should pass keyboard isolation to the selector when requested by the caller', async () => { + renderWorkflowFlowComponent(, { nodes: [], edges: [] }) + + await waitFor(() => expect(latestBlockSelectorProps).not.toBeNull()) + + expect(latestBlockSelectorProps?.isolateKeyboardEvents).toBe(true) + }) }) // User interactions that bridge selector state and workflow state. diff --git a/web/app/components/workflow/operator/add-block.tsx b/web/app/components/workflow/operator/add-block.tsx index e2937f28642..364c55ee93e 100644 --- a/web/app/components/workflow/operator/add-block.tsx +++ b/web/app/components/workflow/operator/add-block.tsx @@ -46,12 +46,14 @@ type AddBlockProps = { renderTriggerAsButtonRoot?: boolean offset?: OffsetOptions onClose?: () => void + isolateKeyboardEvents?: boolean } const AddBlock = ({ renderTrigger, renderTriggerAsButtonRoot, offset, onClose, + isolateKeyboardEvents, }: AddBlockProps) => { const { t } = useTranslation() const store = useStoreApi() @@ -183,6 +185,7 @@ const AddBlock = ({ popupClassName="min-w-[256px]!" availableBlocksTypes={availableNextBlocks} showStartTab={showStartTab} + isolateKeyboardEvents={isolateKeyboardEvents} /> ) } diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index 2e620e26909..f4f37c17fbf 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -97,6 +97,7 @@ export function PanelContextmenu({ renderTrigger={renderAddBlockTrigger} renderTriggerAsButtonRoot onClose={onClose} + isolateKeyboardEvents offset={{ mainAxis: -36, crossAxis: -4,