From 2c206d2792d009a7558ad3264ae70a52a2aec066 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:25:48 +0000 Subject: [PATCH] fix(web): delegate overlay initial focus to popup owners (#42121) --- oxlint-suppressions.json | 5 +- .../plugins/component-picker-block/index.tsx | 1 - .../__tests__/index.spec.tsx | 4 +- .../install-from-github/index.tsx | 7 +- .../__tests__/var-reference-picker.spec.tsx | 1 + .../variable/var-reference-picker.tsx | 3 + .../variable/var-reference-popup.tsx | 5 +- .../variable/var-reference-vars.tsx | 10 +-- .../if-else/components/condition-add.tsx | 11 ++- .../condition-list/condition-var-selector.tsx | 11 ++- .../components/condition-number-input.tsx | 10 ++- .../condition-variable-selector.tsx | 12 +++- .../__tests__/variable-selectors.spec.tsx | 69 +++++++++++++++++++ .../nodes/loop/components/condition-add.tsx | 11 ++- .../condition-list/condition-var-selector.tsx | 11 ++- .../components/condition-number-input.tsx | 10 ++- .../__tests__/index.spec.tsx | 7 +- .../workflow/workflow-generator/index.tsx | 11 +-- 18 files changed, 166 insertions(+), 33 deletions(-) create mode 100644 web/app/components/workflow/nodes/loop/__tests__/variable-selectors.spec.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 4b8ee80ff96..f58391f65a4 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -3751,9 +3751,6 @@ "jsx-a11y/click-events-have-key-events": { "count": 1 }, - "jsx-a11y/no-autofocus": { - "count": 1 - }, "jsx-a11y/no-static-element-interactions": { "count": 1 } @@ -5139,7 +5136,7 @@ }, "web/app/components/workflow/workflow-generator/index.tsx": { "dify/require-disable-directive-description": { - "count": 2 + "count": 1 } }, "web/app/components/workflow/workflow-preview/components/nodes/constants.ts": { diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 97d4184f8ee..4eeade8dfaa 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -267,7 +267,6 @@ const ComponentPicker = ({ onBlur={handleClose} showManageInputField={workflowVariableBlock.showManageInputField} onManageInputField={workflowVariableBlock.onManageInputField} - autoFocus={false} isInCodeGeneratorInstructionEditor={ currentBlock?.generatorType === GeneratorType.code } diff --git a/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx index 2b2f59a7b1a..38a39753ea0 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/__tests__/index.spec.tsx @@ -290,11 +290,12 @@ describe('InstallFromGitHub', () => { // Rendering Tests // ================================ describe('Rendering', () => { - it('should render modal with correct initial state for new installation', () => { + it('should render and focus the URL for a new installation', async () => { render() expect(getRepoUrlInput()).toBeInTheDocument() expect(getRepoUrlInput()).toHaveValue('') + await waitFor(() => expect(getRepoUrlInput()).toHaveFocus()) }) it('should render modal with selectPackage step when updatePayload is provided', () => { @@ -559,6 +560,7 @@ describe('InstallFromGitHub', () => { await waitFor(() => { expect(getRepoUrlInput()).toBeInTheDocument() }) + expect(getRepoUrlInput()).not.toHaveFocus() }) it('should go back from readyToInstall to selectPackage', async () => { diff --git a/web/app/components/plugins/install-plugin/install-from-github/index.tsx b/web/app/components/plugins/install-plugin/install-from-github/index.tsx index 1e65cdf3d02..3be2c3c797d 100644 --- a/web/app/components/plugins/install-plugin/install-from-github/index.tsx +++ b/web/app/components/plugins/install-plugin/install-from-github/index.tsx @@ -10,7 +10,7 @@ import { IconButton } from '@langgenius/dify-ui/icon-button' import { Input } from '@langgenius/dify-ui/input' import { toast } from '@langgenius/dify-ui/toast' import * as React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import useGetIcon from '@/app/components/plugins/install-plugin/base/use-get-icon' import { InstallStepFromGitHub } from '../../types' @@ -42,6 +42,7 @@ const InstallFromGitHub: React.FC = ({ onClose, onSuccess, }) => { + const repoUrlInputRef = useRef(null) const { t } = useTranslation() const { getIconUrl } = useGetIcon() const { refreshPluginList } = useRefreshPluginList() @@ -178,6 +179,7 @@ const InstallFromGitHub: React.FC = ({ }} > = ({ { fireEvent.click(screen.getByTestId('var-reference-picker-trigger')) + await waitFor(() => expect(screen.getByRole('searchbox')).toHaveFocus()) fireEvent.click(await screen.findByText('answer')) expect(onChange).toHaveBeenCalledWith( diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 86aaed0e00d..668f7f20d3f 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -146,6 +146,7 @@ const VarReferencePicker: FC = ({ const isInLoop = !!node?.data.isInLoop const loopNode = isInLoop ? (nodes.find((n) => n.id === node?.parentId) ?? null) : null + const searchInputRef = useRef(null) const triggerRef = useRef(null) const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH) useEffect(() => { @@ -419,12 +420,14 @@ const VarReferencePicker: FC = ({ /> )} {!isConstant && ( vars: NodeOutPutVar[] popupFor?: 'assigned' | 'toAssigned' onChange: (value: ValueSelector, varDetail: Var) => void @@ -17,6 +18,7 @@ type Props = Readonly<{ preferSchemaType?: boolean }> const VarReferencePopup: FC = ({ + searchInputRef, vars, popupFor, onChange, @@ -59,6 +61,7 @@ const VarReferencePopup: FC = ({ ) ) : ( void - autoFocus?: boolean + searchInputRef?: RefObject preferSchemaType?: boolean }> const VarReferenceVars: FC = ({ @@ -351,13 +351,14 @@ const VarReferenceVars: FC = ({ isInCodeGeneratorInstructionEditor, showManageInputField, onManageInputField, - autoFocus = true, + searchInputRef: externalSearchInputRef, preferSchemaType, }) => { const { t } = useTranslation() const [internalSearchValue, setInternalSearchValue] = useState('') const listRef = useRef(null) - const searchInputRef = useRef(null) + const internalSearchInputRef = useRef(null) + const searchInputRef = externalSearchInputRef ?? internalSearchInputRef const searchValue = searchText ?? internalSearchValue const searchLabel = t(($) => $['common.searchVar'], { ns: 'workflow' }) const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue]) @@ -497,7 +498,6 @@ const VarReferenceVars: FC = ({ placeholder={searchLabel} onValueChange={setInternalSearchValue} onKeyDown={handleKeyDown} - autoFocus={autoFocus} /> { + const searchInputRef = useRef(null) const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -47,12 +48,18 @@ const ConditionAdd = ({ }} /> - + diff --git a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx index 60835aa3265..8e0d49d14c4 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-list/condition-var-selector.tsx @@ -6,6 +6,7 @@ import type { VarType, } from '@/app/components/workflow/types' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useRef } from 'react' import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -28,9 +29,11 @@ const ConditionVarSelector = ({ nodesOutputVars, onChange, }: ConditionVarSelectorProps) => { + const searchInputRef = useRef(null) return ( - + diff --git a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx index 90bd5ac79d7..50e3bc84185 100644 --- a/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/if-else/components/condition-number-input.tsx @@ -12,7 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { capitalize } from 'es-toolkit/string' -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -41,6 +41,7 @@ const ConditionNumberInput = ({ isShort, unit, }: ConditionNumberInputProps) => { + const searchInputRef = useRef(null) const { t } = useTranslation() const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false) const [variableSelectorVisible, setVariableSelectorVisible] = useState(false) @@ -106,6 +107,7 @@ const ConditionNumberInput = ({ )} - + diff --git a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx index b78cbbffcf7..6d58946e4aa 100644 --- a/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx +++ b/web/app/components/workflow/nodes/knowledge-retrieval/components/metadata/condition-list/condition-variable-selector.tsx @@ -1,6 +1,6 @@ import type { Node, NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag' @@ -22,6 +22,7 @@ const ConditionVariableSelector = ({ nodesOutputVars = [], onChange, }: ConditionVariableSelectorProps) => { + const searchInputRef = useRef(null) const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -36,6 +37,7 @@ const ConditionVariableSelector = ({ return ( {!!valueSelector.length && ( @@ -63,12 +65,18 @@ const ConditionVariableSelector = ({ } /> - + diff --git a/web/app/components/workflow/nodes/loop/__tests__/variable-selectors.spec.tsx b/web/app/components/workflow/nodes/loop/__tests__/variable-selectors.spec.tsx new file mode 100644 index 00000000000..943649ad1bb --- /dev/null +++ b/web/app/components/workflow/nodes/loop/__tests__/variable-selectors.spec.tsx @@ -0,0 +1,69 @@ +import type { NodeOutPutVar } from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { ReactFlowProvider } from 'reactflow' +import { VarType as NumberVarType } from '@/app/components/workflow/nodes/tool/types' +import { VarType } from '@/app/components/workflow/types' +import ConditionAdd from '../components/condition-add' +import ConditionVarSelector from '../components/condition-list/condition-var-selector' +import ConditionNumberInput from '../components/condition-number-input' + +const answer = { variable: 'answer', type: VarType.number } +const variables: NodeOutPutVar[] = [{ nodeId: 'source', title: 'Source', vars: [answer] }] + +it('adds the variable selected through the search and closes the add popup', async () => { + const user = userEvent.setup() + const onSelectVariable = vi.fn() + render() + await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.addCondition' })) + const search = screen.getByRole('searchbox') + await user.type(search, 'answer{Enter}') + expect(onSelectVariable).toHaveBeenCalledWith(['source', 'answer'], answer) + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument() +}) + +it('writes a variable expression when selecting a numeric comparison value', async () => { + const user = userEvent.setup() + const onValueChange = vi.fn() + render( + , + ) + await user.click(screen.getByRole('button', { name: 'workflow.nodes.ifElse.selectVariable' })) + await user.type(screen.getByRole('searchbox'), 'answer{Enter}') + expect(onValueChange).toHaveBeenCalledWith('{{#source.answer#}}') + expect(screen.queryByRole('searchbox')).not.toBeInTheDocument() +}) + +it('updates the selected condition variable through its controlled popup', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + function ConditionHarness() { + const [open, setOpen] = useState(false) + return ( + + ) + } + render( + + + , + ) + await user.click(screen.getByText('previous')) + await user.type(screen.getByRole('searchbox'), 'answer{Enter}') + expect(onChange).toHaveBeenCalledWith(['source', 'answer'], answer) +}) diff --git a/web/app/components/workflow/nodes/loop/components/condition-add.tsx b/web/app/components/workflow/nodes/loop/components/condition-add.tsx index 2c847375273..3c55de3dc54 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-add.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-add.tsx @@ -3,7 +3,7 @@ import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflo import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { RiAddLine } from '@remixicon/react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -15,6 +15,7 @@ type ConditionAddProps = { } const ConditionAdd = ({ className, variables, onSelectVariable, disabled }: ConditionAddProps) => { + const searchInputRef = useRef(null) const { t } = useTranslation() const [open, setOpen] = useState(false) @@ -40,12 +41,18 @@ const ConditionAdd = ({ className, variables, onSelectVariable, disabled }: Cond }} /> - + diff --git a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx index 76c294ddc4b..e7039950c2b 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-list/condition-var-selector.tsx @@ -6,6 +6,7 @@ import type { VarType, } from '@/app/components/workflow/types' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' +import { useRef } from 'react' import VariableTag from '@/app/components/workflow/nodes/_base/components/variable-tag' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -28,9 +29,11 @@ const ConditionVarSelector = ({ nodesOutputVars, onChange, }: ConditionVarSelectorProps) => { + const searchInputRef = useRef(null) return ( - + diff --git a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx index 90bd5ac79d7..50e3bc84185 100644 --- a/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx +++ b/web/app/components/workflow/nodes/loop/components/condition-number-input.tsx @@ -12,7 +12,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/pop import { RiArrowDownSLine } from '@remixicon/react' import { useBoolean } from 'ahooks' import { capitalize } from 'es-toolkit/string' -import { memo, useCallback, useState } from 'react' +import { memo, useCallback, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import VarReferenceVars from '@/app/components/workflow/nodes/_base/components/variable/var-reference-vars' @@ -41,6 +41,7 @@ const ConditionNumberInput = ({ isShort, unit, }: ConditionNumberInputProps) => { + const searchInputRef = useRef(null) const { t } = useTranslation() const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false) const [variableSelectorVisible, setVariableSelectorVisible] = useState(false) @@ -106,6 +107,7 @@ const ConditionNumberInput = ({ )} - + diff --git a/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx b/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx index 71a9e00d877..25819037497 100644 --- a/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx +++ b/web/app/components/workflow/workflow-generator/__tests__/index.spec.tsx @@ -69,13 +69,18 @@ describe('WorkflowGeneratorModal', () => { }) describe('Accessibility', () => { - it('should expose the dialog title and instruction label', async () => { + it('should expose the dialog title and focus the labeled instruction', async () => { render() expect(screen.getByRole('dialog', { name: /workflowGenerator\.title/i })).toBeInTheDocument() expect( screen.getByRole('textbox', { name: /workflowGenerator\.instruction/i }), ).toBeInTheDocument() + await waitFor(() => + expect( + screen.getByRole('textbox', { name: /workflowGenerator\.instruction/i }), + ).toHaveFocus(), + ) }) it('should keep the instruction field keyboard-operable', async () => { diff --git a/web/app/components/workflow/workflow-generator/index.tsx b/web/app/components/workflow/workflow-generator/index.tsx index bc4d2337e0d..2055a0bd9d5 100644 --- a/web/app/components/workflow/workflow-generator/index.tsx +++ b/web/app/components/workflow/workflow-generator/index.tsx @@ -213,6 +213,7 @@ function WorkflowGeneratorModal() { // Seed from the palette's inline-captured instruction, else the last instruction // generated from (persisted across opens). Captured at mount only — the modal // remounts on each open, so this is just the initial value. + const instructionRef = useRef(null) const [instruction, setInstruction] = useState(initialInstruction || lastInstruction || '') // Planner result, streamed ahead of the graph (null until it lands). const [plan, setPlan] = useState(null) @@ -570,7 +571,10 @@ function WorkflowGeneratorModal() { } }} > - + {/* Left pane: instructions + ideal output + model selector */} @@ -605,10 +609,7 @@ function WorkflowGeneratorModal() { {t(($) => $['workflowGenerator.instruction'])}