Compare commits

...

3 Commits

Author SHA1 Message Date
yyh
987a0e70a6 refactor(web): migrate the member search input
Replace the deprecated legacy Input with an owner-local search composition built on Dify UI Input. Preserve the icon, 26px leading padding, size and state styles, and no-clear behavior while exposing native search semantics and pruning the obsolete suppression.
2026-08-09 12:18:56 +08:00
yyh
60ed66607e fix(web): make the member selector keyboard accessible
Render the Base UI popover trigger and member choices as native buttons with valid phrasing content, semantic names, expanded state, and keyboard behavior. Preserve left alignment and full-width option layout, migrate tests from test ids and CSS state to user-facing contracts, and prune the two resolved a11y suppressions.
2026-08-09 12:13:14 +08:00
yyh
4929a67885 refactor(web): simplify the member selector component
Use a typed function declaration instead of React.FC and remove the redundant React namespace import. Preserve the component API and behavior without mixing this style cleanup into the following accessibility layer.
2026-08-09 12:08:50 +08:00
3 changed files with 133 additions and 79 deletions

View File

@@ -2821,17 +2821,6 @@
"count": 2
}
},
"web/app/components/header/account-setting/members-page/transfer-ownership-modal/member-selector.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
},
"jsx_a11y/no-static-element-interactions": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"web/app/components/header/account-setting/model-provider-page/declarations.ts": {
"erasable-syntax-only/enums": {
"count": 11

View File

@@ -12,6 +12,16 @@ const mockAccounts = [
{ id: '3', name: 'Bob Wilson', email: 'bob@example.com', avatar_url: '' },
]
const getTrigger = () =>
screen.getByRole('button', {
name: 'common.members.transferModal.transferPlaceholder',
})
const getMemberButtons = () =>
screen.getAllByRole('button', {
name: /@example\.com/,
})
describe('MemberSelector', () => {
const mockOnSelect = vi.fn()
@@ -24,22 +34,25 @@ describe('MemberSelector', () => {
it('should render placeholder when no value is selected', () => {
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
expect(getTrigger()).toHaveAttribute('aria-expanded', 'false')
})
it('should render selected member info', () => {
render(<MemberSelector value="1" onSelect={mockOnSelect} />)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.getByText('john@example.com')).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: /John Doe.*john@example\.com/,
}),
).toBeInTheDocument()
})
it('should open dropdown and show filtered list on click', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} exclude={['1']} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.click(getTrigger())
const items = screen.getAllByTestId('member-selector-item')
const items = getMemberButtons()
expect(items).toHaveLength(2) // Jane and Bob (John excluded)
expect(screen.queryByText('John Doe')).not.toBeInTheDocument()
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
@@ -49,10 +62,14 @@ describe('MemberSelector', () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByRole('textbox', { name: 'common.operation.search' }), 'Jane')
await user.click(getTrigger())
const searchInput = screen.getByRole('searchbox', { name: 'common.operation.search' })
expect(searchInput).toHaveAttribute('name', 'query')
expect(searchInput).toHaveAttribute('autocomplete', 'off')
expect(searchInput).toHaveAttribute('enterkeyhint', 'search')
await user.type(searchInput, 'Jane')
const items = screen.getAllByTestId('member-selector-item')
const items = getMemberButtons()
expect(items).toHaveLength(1)
expect(screen.getByText('Jane Smith')).toBeInTheDocument()
expect(screen.queryByText('Bob Wilson')).not.toBeInTheDocument()
@@ -62,13 +79,13 @@ describe('MemberSelector', () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.click(screen.getByText('Jane Smith'))
await user.click(getTrigger())
await user.click(screen.getByRole('button', { name: /Jane Smith.*jane@example\.com/ }))
expect(mockOnSelect).toHaveBeenCalledWith('2')
await waitFor(() => {
expect(
screen.queryByRole('textbox', { name: 'common.operation.search' }),
screen.queryByRole('searchbox', { name: 'common.operation.search' }),
).not.toBeInTheDocument()
})
})
@@ -77,10 +94,10 @@ describe('MemberSelector', () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByRole('textbox', { name: 'common.operation.search' }), 'john@')
await user.click(getTrigger())
await user.type(screen.getByRole('searchbox', { name: 'common.operation.search' }), 'john@')
const items = screen.getAllByTestId('member-selector-item')
const items = getMemberButtons()
expect(items).toHaveLength(1)
expect(screen.getByText('John Doe')).toBeInTheDocument()
expect(screen.queryByText('Jane Smith')).not.toBeInTheDocument()
@@ -89,7 +106,7 @@ describe('MemberSelector', () => {
it('should show placeholder when value does not match any account', () => {
render(<MemberSelector value="nonexistent-id" onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
expect(getTrigger()).toBeInTheDocument()
})
it('should handle missing data gracefully', () => {
@@ -97,7 +114,7 @@ describe('MemberSelector', () => {
typeof useMembers
>)
render(<MemberSelector onSelect={mockOnSelect} />)
expect(screen.getByText(/members\.transferModal\.transferPlaceholder/i)).toBeInTheDocument()
expect(getTrigger()).toBeInTheDocument()
})
it('should filter by email when account name is empty', async () => {
@@ -112,34 +129,35 @@ describe('MemberSelector', () => {
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByRole('textbox', { name: 'common.operation.search' }), 'noname@')
await user.click(getTrigger())
await user.type(screen.getByRole('searchbox', { name: 'common.operation.search' }), 'noname@')
const items = screen.getAllByTestId('member-selector-item')
const items = getMemberButtons()
expect(items).toHaveLength(1)
})
it('should apply hover background class when dropdown is open', async () => {
it('should expose the expanded state while the dropdown is open', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
const trigger = screen.getByTestId('member-selector-trigger')
const trigger = getTrigger()
expect(trigger).toHaveAttribute('aria-expanded', 'false')
await user.click(trigger)
expect(trigger).toHaveAttribute('data-popup-open')
expect(trigger).toHaveAttribute('aria-expanded', 'true')
})
it('should not match account when neither name nor email contains search value', async () => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.click(getTrigger())
await user.type(
screen.getByRole('textbox', { name: 'common.operation.search' }),
screen.getByRole('searchbox', { name: 'common.operation.search' }),
'xyz-no-match-xyz',
)
expect(screen.queryByTestId('member-selector-item')).not.toBeInTheDocument()
expect(screen.queryAllByRole('button', { name: /@example\.com/ })).toHaveLength(0)
})
it('should fall back to empty string for account with undefined email when searching', async () => {
@@ -153,10 +171,42 @@ describe('MemberSelector', () => {
} as unknown as ReturnType<typeof useMembers>)
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(screen.getByTestId('member-selector-trigger'))
await user.type(screen.getByRole('textbox', { name: 'common.operation.search' }), 'john')
await user.click(getTrigger())
await user.type(screen.getByRole('searchbox', { name: 'common.operation.search' }), 'john')
const items = screen.getAllByTestId('member-selector-item')
expect(items).toHaveLength(1)
expect(screen.getByRole('button', { name: /John/ })).toBeInTheDocument()
})
it.each([
['Enter', '{Enter}'],
['Space', ' '],
])('should open the dropdown with the %s key', async (_, key) => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
const trigger = getTrigger()
trigger.focus()
await user.keyboard(key)
expect(trigger).toHaveAttribute('aria-expanded', 'true')
expect(getMemberButtons()).toHaveLength(3)
})
it.each([
['Enter', '{Enter}'],
['Space', ' '],
])('should select a member with the %s key', async (_, key) => {
const user = userEvent.setup()
render(<MemberSelector onSelect={mockOnSelect} />)
await user.click(getTrigger())
const jane = screen.getByRole('button', { name: /Jane Smith.*jane@example\.com/ })
jane.focus()
await user.keyboard(key)
expect(mockOnSelect).toHaveBeenCalledWith('2')
expect(
screen.queryByRole('searchbox', { name: 'common.operation.search' }),
).not.toBeInTheDocument()
})
})

View File

@@ -1,11 +1,9 @@
'use client'
import type { FC } from 'react'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { Input } from '@langgenius/dify-ui/input'
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Input from '@/app/components/base/input'
import { useMembers } from '@/service/use-common'
type Props = Readonly<{
@@ -14,7 +12,7 @@ type Props = Readonly<{
exclude?: string[]
}>
const MemberSelector: FC<Props> = ({ value, onSelect, exclude = [] }) => {
function MemberSelector({ value, onSelect, exclude = [] }: Props) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
@@ -46,28 +44,33 @@ const MemberSelector: FC<Props> = ({ value, onSelect, exclude = [] }) => {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<div
data-testid="member-selector-trigger"
className="group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt"
>
{!currentValue && (
<div className="grow p-1 system-sm-regular text-components-input-text-placeholder">
{t(($) => $['members.transferModal.transferPlaceholder'], { ns: 'common' })}
</div>
)}
{currentValue && (
<>
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
<div className="grow truncate system-sm-medium text-text-secondary">
{currentValue.name}
</div>
<div className="system-xs-regular text-text-quaternary">{currentValue.email}</div>
</>
)}
<div className="i-ri-arrow-down-s-line size-4 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary" />
</div>
<button
type="button"
className="group flex cursor-pointer items-center gap-1.5 rounded-lg bg-components-input-bg-normal px-2 py-1 text-left outline-hidden hover:bg-state-base-hover-alt focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-base-hover-alt"
/>
}
/>
>
{!currentValue && (
<span className="grow p-1 system-sm-regular text-components-input-text-placeholder">
{t(($) => $['members.transferModal.transferPlaceholder'], { ns: 'common' })}
</span>
)}
{currentValue && (
<>
<span aria-hidden>
<Avatar avatar={currentValue.avatar_url} size="sm" name={currentValue.name} />
</span>
<span className="grow truncate system-sm-medium text-text-secondary">
{currentValue.name}
</span>
<span className="system-xs-regular text-text-quaternary">{currentValue.email}</span>
</>
)}
<span
aria-hidden
className="i-ri-arrow-down-s-line size-4 text-text-quaternary group-hover:text-text-secondary group-data-popup-open:text-text-secondary"
/>
</PopoverTrigger>
<PopoverContent
placement="bottom"
sideOffset={4}
@@ -75,30 +78,42 @@ const MemberSelector: FC<Props> = ({ value, onSelect, exclude = [] }) => {
>
<div className="min-w-93 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-xs">
<div className="p-2 pb-1">
<Input
showLeftIcon
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
/>
<div className="relative w-full">
<span
aria-hidden
className="pointer-events-none absolute top-1/2 left-2 i-ri-search-line size-4 -translate-y-1/2 text-components-input-text-placeholder"
/>
<Input
type="search"
name="query"
autoComplete="off"
enterKeyHint="search"
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
className="pl-6.5 [&::-webkit-search-cancel-button]:appearance-none [&::-webkit-search-decoration]:appearance-none"
value={searchValue}
onValueChange={setSearchValue}
/>
</div>
</div>
<div className="p-1">
{filteredList.map((account) => (
<div
<button
type="button"
key={account.id}
data-testid="member-selector-item"
className="flex cursor-pointer items-center gap-2 rounded-lg py-1 pr-3 pl-2 hover:bg-state-base-hover"
className="flex w-full cursor-pointer items-center gap-2 rounded-lg py-1 pr-3 pl-2 text-left outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => {
onSelect(account.id)
setOpen(false)
}}
>
<Avatar avatar={account.avatar_url} size="sm" name={account.name} />
<div className="grow truncate system-sm-medium text-text-secondary">
<span aria-hidden>
<Avatar avatar={account.avatar_url} size="sm" name={account.name} />
</span>
<span className="grow truncate system-sm-medium text-text-secondary">
{account.name}
</div>
<div className="system-xs-regular text-text-quaternary">{account.email}</div>
</div>
</span>
<span className="system-xs-regular text-text-quaternary">{account.email}</span>
</button>
))}
</div>
</div>