fix(web): delegate overlay initial focus to popup owners (#42121)

This commit is contained in:
yyh
2026-09-10 08:25:48 +00:00
committed by GitHub
parent b3c978caa6
commit 2c206d2792
18 changed files with 166 additions and 33 deletions
+1 -4
View File
@@ -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": {
@@ -267,7 +267,6 @@ const ComponentPicker = ({
onBlur={handleClose}
showManageInputField={workflowVariableBlock.showManageInputField}
onManageInputField={workflowVariableBlock.onManageInputField}
autoFocus={false}
isInCodeGeneratorInstructionEditor={
currentBlock?.generatorType === GeneratorType.code
}
@@ -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(<InstallFromGitHub {...defaultProps} />)
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 () => {
@@ -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<InstallFromGitHubProps> = ({
onClose,
onSuccess,
}) => {
const repoUrlInputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation()
const { getIconUrl } = useGetIcon()
const { refreshPluginList } = useRefreshPluginList()
@@ -178,6 +179,7 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({
}}
>
<DialogContent
initialFocus={repoUrlInputRef}
backdropProps={{ forceRender: true }}
className={cn(
'w-140 overflow-hidden! text-left align-middle',
@@ -245,8 +247,7 @@ const InstallFromGitHub: React.FC<InstallFromGitHubProps> = ({
</span>
</FieldLabel>
<Input
// oxlint-disable-next-line jsx-a11y/no-autofocus -- The dialog opens from an explicit install command, and the repository URL is its primary field.
autoFocus
ref={repoUrlInputRef}
type="text"
inputMode="url"
autoComplete="off"
@@ -117,6 +117,7 @@ describe('VarReferencePicker', () => {
fireEvent.click(screen.getByTestId('var-reference-picker-trigger'))
await waitFor(() => expect(screen.getByRole('searchbox')).toHaveFocus())
fireEvent.click(await screen.findByText('answer'))
expect(onChange).toHaveBeenCalledWith(
@@ -146,6 +146,7 @@ const VarReferencePicker: FC<Props> = ({
const isInLoop = !!node?.data.isInLoop
const loopNode = isInLoop ? (nodes.find((n) => n.id === node?.parentId) ?? null) : null
const searchInputRef = useRef<HTMLInputElement>(null)
const triggerRef = useRef<HTMLDivElement>(null)
const [triggerWidth, setTriggerWidth] = useState(TRIGGER_DEFAULT_WIDTH)
useEffect(() => {
@@ -419,12 +420,14 @@ const VarReferencePicker: FC<Props> = ({
/>
)}
<PopoverContent
initialFocus={searchInputRef}
placement={isAddBtnTrigger ? 'bottom-end' : 'bottom-start'}
sideOffset={4}
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
{!isConstant && (
<VarReferencePopup
searchInputRef={searchInputRef}
vars={outputVars}
popupFor={popupFor}
onChange={handleVarReferenceChange}
@@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { FC, RefObject } from 'react'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import * as React from 'react'
import { useMemo } from 'react'
@@ -9,6 +9,7 @@ import { useStore } from '@/app/components/workflow/store'
import VarReferenceVars from './var-reference-vars'
type Props = Readonly<{
searchInputRef?: RefObject<HTMLInputElement | null>
vars: NodeOutPutVar[]
popupFor?: 'assigned' | 'toAssigned'
onChange: (value: ValueSelector, varDetail: Var) => void
@@ -17,6 +18,7 @@ type Props = Readonly<{
preferSchemaType?: boolean
}>
const VarReferencePopup: FC<Props> = ({
searchInputRef,
vars,
popupFor,
onChange,
@@ -59,6 +61,7 @@ const VarReferencePopup: FC<Props> = ({
)
) : (
<VarReferenceVars
searchInputRef={searchInputRef}
searchBoxClassName="mt-1"
vars={vars}
onChange={onChange}
@@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { FC, RefObject } from 'react'
import type { Field, StructuredOutput } from '../../../llm/types'
import type { NodeOutPutVar, ValueSelector, Var } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
@@ -334,7 +334,7 @@ type Props = Readonly<{
isInCodeGeneratorInstructionEditor?: boolean
showManageInputField?: boolean
onManageInputField?: () => void
autoFocus?: boolean
searchInputRef?: RefObject<HTMLInputElement | null>
preferSchemaType?: boolean
}>
const VarReferenceVars: FC<Props> = ({
@@ -351,13 +351,14 @@ const VarReferenceVars: FC<Props> = ({
isInCodeGeneratorInstructionEditor,
showManageInputField,
onManageInputField,
autoFocus = true,
searchInputRef: externalSearchInputRef,
preferSchemaType,
}) => {
const { t } = useTranslation()
const [internalSearchValue, setInternalSearchValue] = useState('')
const listRef = useRef<HTMLDivElement>(null)
const searchInputRef = useRef<HTMLInputElement>(null)
const internalSearchInputRef = useRef<HTMLInputElement>(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<Props> = ({
placeholder={searchLabel}
onValueChange={setInternalSearchValue}
onKeyDown={handleKeyDown}
autoFocus={autoFocus}
/>
<InputGroupAddon className="ps-2 pe-0.5">
<span
@@ -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'
@@ -22,6 +22,7 @@ const ConditionAdd = ({
onSelectVariable,
disabled,
}: ConditionAddProps) => {
const searchInputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -47,12 +48,18 @@ const ConditionAdd = ({
}}
/>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={4}
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="w-74 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars vars={variables} isSupportFileVar onChange={handleSelectVariable} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={variables}
isSupportFileVar
onChange={handleSelectVariable}
/>
</div>
</PopoverContent>
</Popover>
@@ -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<HTMLInputElement>(null)
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
// TODO: Declare non-native button semantics for this div trigger to support keyboard activation.
render={
<div className="w-full cursor-pointer">
<VariableTag
@@ -43,12 +46,18 @@ const ConditionVarSelector = ({
}
/>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={4}
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="w-74 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars vars={nodesOutputVars} isSupportFileVar onChange={onChange} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={nodesOutputVars}
isSupportFileVar
onChange={onChange}
/>
</div>
</PopoverContent>
</Popover>
@@ -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<HTMLInputElement>(null)
const { t } = useTranslation()
const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false)
const [variableSelectorVisible, setVariableSelectorVisible] = useState(false)
@@ -106,6 +107,7 @@ const ConditionNumberInput = ({
)}
</PopoverTrigger>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={2}
className="border-none bg-transparent shadow-none"
@@ -116,7 +118,11 @@ const ConditionNumberInput = ({
isShort && 'w-50',
)}
>
<VarReferenceVars vars={variables} onChange={handleSelectVariable} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={variables}
onChange={handleSelectVariable}
/>
</div>
</PopoverContent>
</Popover>
@@ -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<HTMLInputElement>(null)
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -36,6 +37,7 @@ const ConditionVariableSelector = ({
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
// TODO: Declare non-native button semantics for this div trigger to support keyboard activation.
render={
<div className="flex h-6 grow cursor-pointer items-center">
{!!valueSelector.length && (
@@ -63,12 +65,18 @@ const ConditionVariableSelector = ({
}
/>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={4}
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="w-74 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars vars={nodesOutputVars} isSupportFileVar onChange={handleChange} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={nodesOutputVars}
isSupportFileVar
onChange={handleChange}
/>
</div>
</PopoverContent>
</Popover>
@@ -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(<ConditionAdd variables={variables} onSelectVariable={onSelectVariable} />)
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(
<ConditionNumberInput
numberVarType={NumberVarType.variable}
value=""
variables={variables}
onNumberVarTypeChange={vi.fn()}
onValueChange={onValueChange}
/>,
)
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 (
<ConditionVarSelector
open={open}
onOpenChange={setOpen}
valueSelector={['source', 'previous']}
varType={VarType.number}
availableNodes={[]}
nodesOutputVars={variables}
onChange={onChange}
/>
)
}
render(
<ReactFlowProvider>
<ConditionHarness />
</ReactFlowProvider>,
)
await user.click(screen.getByText('previous'))
await user.type(screen.getByRole('searchbox'), 'answer{Enter}')
expect(onChange).toHaveBeenCalledWith(['source', 'answer'], answer)
})
@@ -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<HTMLInputElement>(null)
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@@ -40,12 +41,18 @@ const ConditionAdd = ({ className, variables, onSelectVariable, disabled }: Cond
}}
/>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={4}
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="w-74 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars vars={variables} isSupportFileVar onChange={handleSelectVariable} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={variables}
isSupportFileVar
onChange={handleSelectVariable}
/>
</div>
</PopoverContent>
</Popover>
@@ -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<HTMLInputElement>(null)
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
// TODO: Declare non-native button semantics for this div trigger to support keyboard activation.
render={
<div className="cursor-pointer">
<VariableTag
@@ -43,12 +46,18 @@ const ConditionVarSelector = ({
}
/>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={4}
className="border-none bg-transparent p-0 shadow-none backdrop-blur-none"
>
<div className="w-74 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg">
<VarReferenceVars vars={nodesOutputVars} isSupportFileVar onChange={onChange} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={nodesOutputVars}
isSupportFileVar
onChange={onChange}
/>
</div>
</PopoverContent>
</Popover>
@@ -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<HTMLInputElement>(null)
const { t } = useTranslation()
const [numberVarTypeVisible, setNumberVarTypeVisible] = useState(false)
const [variableSelectorVisible, setVariableSelectorVisible] = useState(false)
@@ -106,6 +107,7 @@ const ConditionNumberInput = ({
)}
</PopoverTrigger>
<PopoverContent
initialFocus={searchInputRef}
placement="bottom-start"
sideOffset={2}
className="border-none bg-transparent shadow-none"
@@ -116,7 +118,11 @@ const ConditionNumberInput = ({
isShort && 'w-50',
)}
>
<VarReferenceVars vars={variables} onChange={handleSelectVariable} />
<VarReferenceVars
searchInputRef={searchInputRef}
vars={variables}
onChange={handleSelectVariable}
/>
</div>
</PopoverContent>
</Popover>
@@ -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(<WorkflowGeneratorModal />)
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 () => {
@@ -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<HTMLTextAreaElement>(null)
const [instruction, setInstruction] = useState(initialInstruction || lastInstruction || '')
// Planner result, streamed ahead of the graph (null until it lands).
const [plan, setPlan] = useState<WorkflowGenPlan | null>(null)
@@ -570,7 +571,10 @@ function WorkflowGeneratorModal() {
}
}}
>
<DialogContent className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[calc(100vw-2rem)] max-w-285! min-w-0 overflow-hidden! border-none p-0! text-left align-middle">
<DialogContent
initialFocus={instructionRef}
className="h-[min(680px,calc(100dvh-2rem))] max-h-none! w-[calc(100vw-2rem)] max-w-285! min-w-0 overflow-hidden! border-none p-0! text-left align-middle"
>
<div className="flex h-full min-h-0 flex-col md:flex-row">
{/* Left pane: instructions + ideal output + model selector */}
<div className="max-h-[55%] w-full shrink-0 overflow-y-auto border-b border-divider-regular p-6 md:h-full md:max-h-none md:w-1/2 md:border-r md:border-b-0 lg:w-142.5">
@@ -605,10 +609,7 @@ function WorkflowGeneratorModal() {
{t(($) => $['workflowGenerator.instruction'])}
</FieldLabel>
<Textarea
// Autofocus is appropriate here: the modal's sole purpose is to
// capture an instruction, so focusing it on open aids the flow.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
ref={instructionRef}
className="h-40"
placeholder={
isRefine