fix: improve members role chip accessibility (#38037)

This commit is contained in:
yyh 2026-06-27 02:40:03 +08:00 committed by GitHub
parent 446b3962c1
commit 17bee5fb32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 126 additions and 160 deletions

View File

@ -1,7 +1,7 @@
import type { AppContextValue } from '@/context/app-context'
import type { Role } from '@/models/access-control'
import type { ICurrentWorkspace, Member } from '@/models/common'
import { screen } from '@testing-library/react'
import { screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { vi } from 'vitest'
import { createMockProviderContextValue } from '@/__mocks__/provider-context'
@ -24,6 +24,10 @@ const renderMembersPage = () => renderWithSystemFeatures(<MembersPage />, {
systemFeatures: { is_email_setup: true },
})
const getMemberDetailsButton = (memberId: string) => within(screen.getByTestId(`member-row-${memberId}`)).getByRole('button', {
name: /members\.memberDetails\.openAria/i,
})
const createRole = (overrides: Partial<Role>): Role => ({
id: 'role-1',
tenant_id: 'tenant-1',
@ -220,8 +224,8 @@ describe('MembersPage', () => {
expect(screen.getByText('common.members.name', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('w-65', 'shrink-0')
expect(screen.getByText('common.members.role', { selector: '.system-xs-medium-uppercase' }))!.toHaveClass('min-w-0', 'grow')
expect(screen.getByTestId('member-row-1').children[0])!.toHaveClass('w-65', 'shrink-0')
expect(screen.getByTestId('member-row-1').children[2])!.toHaveClass('min-w-0', 'grow')
expect(getMemberDetailsButton('1').children[0])!.toHaveClass('w-65', 'shrink-0')
expect(getMemberDetailsButton('1').children[2])!.toHaveClass('min-w-0', 'grow')
})
it('should render plural roles column header when RBAC is enabled', () => {
@ -495,12 +499,26 @@ describe('MembersPage', () => {
expect(screen.getByText('Admin'))!.toBeInTheDocument()
})
it('should expose member details as a native row button without nesting member actions', () => {
renderMembersPage()
const row = screen.getByTestId('member-row-2')
const detailsButton = getMemberDetailsButton('2')
const memberMenu = within(row).getByTestId('member-menu')
expect(row).not.toHaveAttribute('role', 'button')
expect(row).not.toHaveClass('hover:bg-state-base-hover')
expect(detailsButton).toHaveAttribute('type', 'button')
expect(detailsButton).toHaveClass('hover:bg-state-base-hover', 'focus-visible:bg-state-base-hover')
expect(detailsButton).not.toContainElement(memberMenu)
})
it('should open member details modal when a member row is clicked', async () => {
const user = userEvent.setup()
renderMembersPage()
await user.click(screen.getByTestId('member-row-2'))
await user.click(getMemberDetailsButton('2'))
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
expect(screen.getByTestId('details-member-name'))!.toHaveTextContent('Admin User')
@ -514,8 +532,8 @@ describe('MembersPage', () => {
renderMembersPage()
const row = screen.getByTestId('member-row-2')
row.focus()
const detailsButton = getMemberDetailsButton('2')
detailsButton.focus()
await user.keyboard('{Enter}')
expect(screen.getByText('Member Details Modal'))!.toBeInTheDocument()
@ -526,7 +544,7 @@ describe('MembersPage', () => {
renderMembersPage()
await user.click(screen.getByTestId('member-row-1'))
await user.click(getMemberDetailsButton('1'))
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
})
@ -543,7 +561,7 @@ describe('MembersPage', () => {
renderMembersPage()
await user.click(screen.getByTestId('member-row-2'))
await user.click(getMemberDetailsButton('2'))
expect(screen.getByTestId('details-can-assign'))!.toHaveTextContent('false')
})
@ -553,7 +571,7 @@ describe('MembersPage', () => {
renderMembersPage()
await user.click(screen.getByTestId('member-row-2'))
await user.click(getMemberDetailsButton('2'))
await user.click(screen.getByRole('button', { name: 'Submit Member Roles' }))
expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({
@ -575,7 +593,7 @@ describe('MembersPage', () => {
},
})
await user.click(screen.getByTestId('member-row-2'))
await user.click(getMemberDetailsButton('2'))
await user.click(screen.getByRole('button', { name: 'Submit Member Roles' }))
expect(mockUpdateRolesOfMember).toHaveBeenCalledWith({

View File

@ -145,11 +145,11 @@ describe('MemberDetailsModal', () => {
/>,
)
expect(screen.queryByRole('button', { name: /Custom role/i })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /^Custom role$/i })).toBeInTheDocument()
await user.click(screen.getByText('Custom role'))
await user.click(screen.getByRole('button', { name: /^Custom role$/i }))
expect(screen.queryByRole('menuitem', { name: /common\.operation\.remove/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /common\.operation\.remove/i })).not.toBeInTheDocument()
})
it('should not show role removal controls when role assignment is not allowed', () => {
@ -189,9 +189,7 @@ describe('MemberDetailsModal', () => {
/>,
)
await user.click(screen.getByRole('button', { name: /Custom role/i }))
await user.click(screen.getByRole('menuitem', { name: /common\.operation\.remove/i }))
await user.click(screen.getByRole('button', { name: /common\.operation\.remove.*Custom role/i }))
expect(handleAssignSubmit).not.toHaveBeenCalled()

View File

@ -2,17 +2,11 @@
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu'
import {
PreviewCard,
PreviewCardContent,
PreviewCardTrigger,
} from '@langgenius/dify-ui/preview-card'
import { memo, useState } from 'react'
Popover,
PopoverContent,
PopoverTrigger,
} from '@langgenius/dify-ui/popover'
import { memo } from 'react'
import { Trans, useTranslation } from 'react-i18next'
type PermissionRoleChipProps = {
@ -33,9 +27,8 @@ const PermissionRoleChip = ({
className,
}: PermissionRoleChipProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const permissions = permissionKeys
const canOpenMenu = !isOwner && !!onRemove
const canRemoveRole = !isOwner && !!onRemove
const permissionLabels = permissions
.map(key => t(key, {
ns: 'permissionKeys',
@ -44,42 +37,42 @@ const PermissionRoleChip = ({
.join(', ')
const hasPermissionLabels = permissionLabels.length > 0
const chipClassName = cn(
'inline-flex h-6 max-w-full items-center rounded-full border-[0.5px] border-components-panel-border-subtle bg-background-body p-1 system-xs-regular text-text-primary shadow-xs transition-colors outline-none',
canOpenMenu && 'cursor-pointer hover:bg-background-section-burn focus-visible:ring-1 focus-visible:ring-components-input-border-active focus-visible:outline-hidden',
open && 'bg-background-section-burn',
const chipRootClassName = cn(
'inline-flex h-6 max-w-full min-w-0 items-center gap-1 rounded-full border-[0.5px] border-components-panel-border-subtle bg-background-body px-1.5 py-0.5 system-xs-medium text-text-primary shadow-xs transition-colors',
'hover:bg-background-section-burn has-[[data-popup-open]]:bg-background-section-burn',
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-state-accent-solid',
className,
)
const chipContent = (
<span className="min-w-0 truncate px-1 leading-4">{label}</span>
)
const removeLabel = `${t('operation.remove', { ns: 'common' })} ${label}`
const chip = canOpenMenu
? (
const chip = (
<span className={chipRootClassName}>
<PopoverTrigger
openOnHover
delay={300}
closeDelay={200}
render={(
<button
type="button"
className="min-w-0 truncate rounded-sm border-none bg-transparent p-0 text-start leading-4 outline-hidden"
>
{label}
</button>
)}
/>
{canRemoveRole && (
<button
type="button"
className={chipClassName}
aria-label={t('members.memberDetails.roleActionsAria', {
ns: 'common',
role: label,
defaultValue: 'Open actions for {{role}} role',
})}
data-testid="permission-role-chip"
data-role-id={roleId}
aria-label={removeLabel}
className="flex size-3.5 shrink-0 items-center justify-center rounded-full border-none bg-transparent p-0 text-text-tertiary outline-hidden hover:bg-state-base-hover-alt hover:text-text-secondary focus-visible:bg-state-base-hover-alt focus-visible:text-text-secondary"
onClick={() => onRemove?.(roleId)}
>
{chipContent}
<span aria-hidden className="i-ri-close-line size-3" />
</button>
)
: (
<span
className={chipClassName}
data-testid="permission-role-chip"
data-role-id={roleId}
>
{chipContent}
</span>
)
)}
</span>
)
const permissionSummary = (
<div className="flex w-58 flex-col gap-1 px-4 py-3.5">
@ -109,53 +102,17 @@ const PermissionRoleChip = ({
</div>
)
const chipWithPermissions = (
<PreviewCard>
<PreviewCardTrigger render={chip} />
<PreviewCardContent
placement="bottom-start"
sideOffset={8}
popupClassName="overflow-hidden border-components-panel-border bg-components-tooltip-bg p-0 shadow-lg backdrop-blur-[5px]"
>
{permissionSummary}
</PreviewCardContent>
</PreviewCard>
)
if (!canOpenMenu)
return chipWithPermissions
const menuTrigger = (
<PreviewCard>
<DropdownMenuTrigger render={<PreviewCardTrigger render={chip} />} />
<PreviewCardContent
placement="bottom-start"
sideOffset={8}
popupClassName="overflow-hidden border-components-panel-border bg-components-tooltip-bg p-0 shadow-lg backdrop-blur-[5px]"
>
{permissionSummary}
</PreviewCardContent>
</PreviewCard>
)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
{menuTrigger}
<DropdownMenuContent
<Popover>
{chip}
<PopoverContent
placement="bottom-start"
sideOffset={4}
popupClassName="w-[236px] rounded-xl p-1"
sideOffset={8}
popupClassName="overflow-hidden border-components-panel-border bg-components-tooltip-bg p-0 shadow-lg backdrop-blur-[5px]"
>
<DropdownMenuItem
variant="destructive"
className="h-8 gap-2 rounded-lg px-2 py-1 system-sm-regular"
onClick={() => onRemove?.(roleId)}
>
<span aria-hidden className="i-ri-delete-bin-line size-4 shrink-0" />
{t('operation.remove', { ns: 'common' })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{permissionSummary}
</PopoverContent>
</Popover>
)
}

View File

@ -10,7 +10,6 @@ import {
AlertDialogDescription,
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { cn } from '@langgenius/dify-ui/cn'
import {
DropdownMenu,
DropdownMenuContent,
@ -108,26 +107,17 @@ const MemberMenu = ({
onTransferOwnership?.()
}, [onTransferOwnership])
const stopPropagationOnClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
}, [])
const stopPropagationOnKeyDown = useCallback((e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ')
e.stopPropagation()
}, [])
if (!canAssignRoles && !canRemove && !showTransferOwnership)
return null
return (
<div role="presentation" onClick={stopPropagationOnClick} onKeyDown={stopPropagationOnKeyDown}>
<div role="presentation">
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
render={(
<ActionButton
size="l"
className={cn(open && 'bg-state-base-hover')}
className="data-popup-open:bg-state-base-hover"
aria-label={t('members.memberActions', { ns: 'common', defaultValue: 'Member actions' })}
/>
)}

View File

@ -1,7 +1,7 @@
'use client'
import type { KeyboardEvent } from 'react'
import type { Member } from '@/models/common'
import { Avatar } from '@langgenius/dify-ui/avatar'
import { cn } from '@langgenius/dify-ui/cn'
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@ -41,57 +41,60 @@ const MemberRow = ({
onOpenDetails(member)
}, [member, onOpenDetails])
const handleRowKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
openDetails()
}
}, [openDetails])
return (
<div
role="button"
tabIndex={0}
data-testid={`member-row-${member.id}`}
aria-label={t('members.memberDetails.openAria', {
ns: 'common',
name: member.name,
defaultValue: 'Open member details for {{name}}',
})}
className="flex cursor-pointer border-b border-divider-subtle hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden"
onClick={openDetails}
onKeyDown={handleRowKeyDown}
className="relative border-b border-divider-subtle"
>
<div className="flex w-65 shrink-0 items-center px-3 py-2">
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
<div className="">
<div className="system-sm-medium text-text-secondary">
{member.name}
{member.status === 'pending' && (
<span className="ml-1 system-xs-medium text-text-warning">
{t('members.pending', { ns: 'common' })}
</span>
)}
{isCurrentUser && (
<span className="system-xs-regular text-text-tertiary">
{t('members.you', { ns: 'common' })}
</span>
)}
</div>
<div className="system-xs-regular text-text-tertiary">{member.email}</div>
</div>
</div>
<div className="flex w-30 shrink-0 items-center py-2 system-sm-regular text-text-secondary">
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
</div>
<button
type="button"
aria-label={t('members.memberDetails.openAria', {
ns: 'common',
name: member.name,
defaultValue: 'Open member details for {{name}}',
})}
className={cn(
'flex w-full min-w-0 cursor-pointer bg-transparent text-left hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:outline-hidden',
canManage && 'pr-12',
)}
onClick={openDetails}
>
<span className="flex w-65 shrink-0 items-center px-3 py-2">
<Avatar avatar={member.avatar_url} size="sm" className="mr-2" name={member.name} />
<span className="min-w-0">
<span className="block system-sm-medium text-text-secondary">
{member.name}
{member.status === 'pending' && (
<span className="ml-1 system-xs-medium text-text-warning">
{t('members.pending', { ns: 'common' })}
</span>
)}
{isCurrentUser && (
<span className="system-xs-regular text-text-tertiary">
{t('members.you', { ns: 'common' })}
</span>
)}
</span>
<span className="block system-xs-regular text-text-tertiary">{member.email}</span>
</span>
</span>
<span className="flex w-30 shrink-0 items-center py-2 system-sm-regular text-text-secondary">
{formatTimeFromNow(Number((member.last_active_at || member.created_at)) * 1000)}
</span>
<span
className="flex min-w-0 grow items-center gap-2 px-3"
role="presentation"
>
<RoleBadges
className="grow"
roleNames={roleNames}
/>
</span>
</button>
<div
className="flex min-w-0 grow items-center gap-2 px-3"
className="absolute inset-y-0 right-0 flex items-center px-3"
role="presentation"
>
<RoleBadges
className="grow"
roleNames={roleNames}
/>
{canManage && (
<MemberMenu
member={member}

View File

@ -33,7 +33,7 @@ const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => {
const overflow = roleNames.slice(max)
return (
<div className={cn('flex min-w-0 items-center gap-1', className)}>
<span className={cn('flex min-w-0 items-center gap-1', className)}>
{visible.map(role => (
<RoleBadge key={role} label={role} />
))}
@ -44,7 +44,7 @@ const RoleBadges = ({ roleNames, max = 2, className }: RoleBadgesProps) => {
{`+${overflow.length}`}
</span>
)}
</div>
</span>
)
}