fix(web): improve home accessibility (#39938)

This commit is contained in:
yyh
2026-08-03 13:41:15 +00:00
committed by GitHub
parent 612ad161bc
commit 984045190c
9 changed files with 124 additions and 72 deletions
-5
View File
@@ -2840,11 +2840,6 @@
"count": 1
}
},
"web/app/components/explore/learn-dify/item.tsx": {
"jsx_a11y/no-noninteractive-element-interactions": {
"count": 1
}
},
"web/app/components/explore/try-app/app/text-generation.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
@@ -803,8 +803,7 @@ describe('AppList', () => {
renderAppList()
const card = screen.getByRole('button', { name: 'Preview Only App' })
expect(card).toHaveClass('opacity-60')
const card = screen.getByRole('button', { name: /Preview Only App.*app\.types\.chatbot/ })
expect(card).toHaveAttribute('aria-disabled', 'true')
expect(screen.queryByRole('link', { name: /Preview Only App/ })).not.toBeInTheDocument()
expect(screen.getByText('Readonly Author')).toBeInTheDocument()
@@ -31,7 +31,7 @@ export function ExploreAppListHeader({
href="https://marketplace.dify.ai/templates"
target="_blank"
rel="noopener noreferrer"
className="flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary hover:text-text-secondary"
className="-m-1 flex min-h-6 shrink-0 touch-manipulation items-center gap-1 rounded-md p-1 system-xs-medium text-text-tertiary outline-hidden transition-colors hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
>
{t(($) => $['apps.viewMore'], { ns: 'explore' })}
<span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" />
@@ -1,6 +1,7 @@
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
import type { AnchorHTMLAttributes, ReactNode } from 'react'
import { fireEvent, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppACLPermission } from '@/utils/permission'
import ContinueWorkItem from '../item'
@@ -86,6 +87,7 @@ describe('ContinueWorkItem', () => {
const link = screen.getByRole('link', { name: /Continue App/ })
expect(link).toHaveAttribute('href', '/app/app-1/configuration')
expect(link).toHaveAccessibleDescription(/Alice.*5 minutes ago/)
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(
screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'),
@@ -93,6 +95,29 @@ describe('ContinueWorkItem', () => {
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
})
it.each([
['chat', 'app.types.chatbot'],
['advanced-chat', 'app.types.advanced'],
['agent-chat', 'app.types.agent'],
['workflow', 'app.types.workflow'],
['completion', 'app.types.completion'],
] as const)('should include the %s app mode in the accessible name', (mode, label) => {
renderItem(createApp({ mode }))
expect(screen.getByRole('link', { name: /Continue App/ })).toHaveAccessibleName(
new RegExp(label.replaceAll('.', '\\.'), 'i'),
)
})
it('should omit the author separator when the author is unavailable', () => {
renderItem(createApp({ author_name: null }))
expect(screen.queryByText('·')).not.toBeInTheDocument()
expect(
screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'),
).toBeInTheDocument()
})
it('should link to access config when RBAC is enabled and only access config permission is available', () => {
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }))
@@ -113,39 +138,34 @@ describe('ContinueWorkItem', () => {
)
})
it('should render preview-only apps as disabled buttons and warn on click', () => {
it('should render preview-only apps as disabled buttons and warn on click', async () => {
const user = userEvent.setup()
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
const card = screen.getByRole('button', { name: 'Continue App' })
const card = screen.getByRole('button', { name: /Continue App.*app\.types\.chatbot/i })
expect(card).toHaveAttribute('type', 'button')
expect(card).toHaveAttribute('aria-disabled', 'true')
expect(card).toHaveClass('cursor-not-allowed')
expect(card).toHaveClass('opacity-60')
expect(card).toHaveAccessibleDescription(/Alice.*5 minutes ago/)
expect(screen.queryByRole('link', { name: /Continue App/ })).not.toBeInTheDocument()
fireEvent.click(card)
await user.click(card)
expect(toastMocks.warning).toHaveBeenCalledWith('app.noAccessResourcePermission')
})
it('should warn when activating a preview-only app with Enter or Space', () => {
it('should warn when activating a preview-only app with Enter or Space', async () => {
const user = userEvent.setup()
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
const card = screen.getByRole('button', { name: 'Continue App' })
const card = screen.getByRole('button', { name: /Continue App.*app\.types\.chatbot/i })
fireEvent.keyDown(card, { key: 'Enter' })
fireEvent.keyDown(card, { key: ' ' })
card.focus()
await user.keyboard('{Enter}')
await user.keyboard(' ')
expect(toastMocks.warning).toHaveBeenCalledTimes(2)
expect(toastMocks.warning).toHaveBeenNthCalledWith(1, 'app.noAccessResourcePermission')
expect(toastMocks.warning).toHaveBeenNthCalledWith(2, 'app.noAccessResourcePermission')
})
it('should ignore other keys on preview-only app cards', () => {
renderItem(createApp({ permission_keys: [AppACLPermission.Preview] }))
fireEvent.keyDown(screen.getByRole('button', { name: 'Continue App' }), { key: 'Escape' })
expect(toastMocks.warning).not.toHaveBeenCalled()
})
})
@@ -28,7 +28,7 @@ const ContinueWork = ({ apps, className }: ContinueWorkProps) => {
</h2>
<Link
href="/apps"
className="ml-4 flex shrink-0 items-center gap-1 system-xs-medium text-text-tertiary"
className="-my-1 -mr-1 ml-3 flex min-h-6 shrink-0 touch-manipulation items-center gap-1 rounded-md p-1 system-xs-medium text-text-tertiary outline-hidden transition-colors hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
>
{t(($) => $['continueWork.exploreStudio'], { ns: 'explore' })}
<span className="i-ri-arrow-right-line size-3 shrink-0" aria-hidden="true" />
@@ -17,6 +17,14 @@ import Link from '@/next/link'
import { getRedirectionPath } from '@/utils/app-redirection'
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
const appModeLabelKeys = {
'advanced-chat': 'types.advanced',
'agent-chat': 'types.agent',
chat: 'types.chatbot',
completion: 'types.completion',
workflow: 'types.workflow',
} as const satisfies Record<RecentAppResponse['mode'], string>
type ContinueWorkItemProps = {
app: RecentAppResponse
}
@@ -27,8 +35,12 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
const currentUserId = useAtomValue(userProfileIdAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const appNameId = React.useId()
const appModeId = React.useId()
const appMetadataId = React.useId()
const isRbacEnabled = systemFeatures.rbac_enabled
const updatedAt = app.updated_at * 1000
const appModeLabel = t(($) => $[appModeLabelKeys[app.mode]], { ns: 'app' })
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
const href = getRedirectionPath(app, {
currentUserId,
@@ -37,7 +49,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
isRbacEnabled,
})
const cardClassName = cn(
'flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 shadow-xs shadow-shadow-shadow-3',
'relative flex min-w-0 items-center gap-3 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg px-4 pt-4 pb-4 text-left shadow-xs shadow-shadow-shadow-3',
isPreviewOnly && 'cursor-not-allowed opacity-60',
)
@@ -45,13 +57,6 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
toast.warning(t(($) => $.noAccessResourcePermission, { ns: 'app' }))
}
const handlePreviewOnlyCardKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
showPreviewOnlyAccessWarning()
}
const cardContent = (
<>
<div className="relative shrink-0">
@@ -69,10 +74,24 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
/>
</div>
<div className="min-w-0 py-px">
<h3 className="truncate system-md-semibold text-text-secondary">{app.name}</h3>
<div className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary">
<span className="shrink-0">{app.author_name}</span>
<span className="shrink-0">·</span>
<h3 id={appNameId} className="truncate system-md-semibold text-text-secondary">
{app.name}
</h3>
<span id={appModeId} className="sr-only">
{appModeLabel}
</span>
<div
id={appMetadataId}
className="flex min-w-0 items-center gap-1 system-xs-regular text-text-tertiary"
>
{app.author_name && (
<>
<span className="min-w-0 truncate">{app.author_name}</span>
<span className="shrink-0" aria-hidden="true">
·
</span>
</>
)}
<span className="min-w-0 truncate">
{t(($) => $['continueWork.editedAt'], {
ns: 'explore',
@@ -86,22 +105,30 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
if (isPreviewOnly) {
return (
<div
role="button"
tabIndex={0}
aria-label={app.name}
aria-disabled="true"
className={cardClassName}
onClick={showPreviewOnlyAccessWarning}
onKeyDown={handlePreviewOnlyCardKeyDown}
>
<div className={cardClassName}>
<button
type="button"
aria-labelledby={`${appNameId} ${appModeId}`}
aria-describedby={appMetadataId}
aria-disabled="true"
className="absolute inset-0 z-10 cursor-not-allowed touch-manipulation appearance-none rounded-xl border-0 bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
onClick={showPreviewOnlyAccessWarning}
/>
{cardContent}
</div>
)
}
return (
<Link href={href} className={cardClassName}>
<Link
href={href}
aria-labelledby={`${appNameId} ${appModeId}`}
aria-describedby={appMetadataId}
className={cn(
cardClassName,
'touch-manipulation outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
)}
>
{cardContent}
</Link>
)
@@ -1,7 +1,8 @@
import type { DeploymentEdition } from '@dify/contracts/api/console/system-features/types.gen'
import type { ReactElement } from 'react'
import type { App } from '@/models/explore'
import { fireEvent, screen } from '@testing-library/react'
import { screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { trackEvent } from '@/app/components/base/amplitude'
import { renderWithConsoleQuery } from '@/test/console/query-data'
import { AppModeEnum } from '@/types/app'
@@ -57,14 +58,15 @@ describe('LearnDifyItem', () => {
expect(screen.queryByText('explore.appCard.try')).not.toBeInTheDocument()
})
it('should create app when card is clicked outside cloud edition', () => {
it('should create app when card is clicked outside cloud edition', async () => {
deploymentEdition = 'COMMUNITY'
const app = createApp()
const onCreate = vi.fn()
const user = userEvent.setup()
render(<LearnDifyItem canCreate item={app} onCreate={onCreate} onTry={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Learn Dify App' }))
await user.click(screen.getByRole('button', { name: 'Learn Dify App' }))
expect(onCreate).toHaveBeenCalledWith(app)
expect(mockTrackEvent).not.toHaveBeenCalled()
@@ -78,13 +80,14 @@ describe('LearnDifyItem', () => {
expect(screen.queryByRole('button', { name: 'Learn Dify App' })).not.toBeInTheDocument()
})
it('should open detail when card is clicked in cloud edition', () => {
it('should open detail when card is clicked in cloud edition', async () => {
const onTry = vi.fn()
const app = createApp()
const user = userEvent.setup()
render(<LearnDifyItem canCreate={false} item={app} onTry={onTry} />)
fireEvent.click(screen.getByRole('button', { name: 'Learn Dify App' }))
await user.click(screen.getByRole('button', { name: 'Learn Dify App' }))
expect(onTry).toHaveBeenCalledWith({ appId: app.app_id, app })
expect(mockTrackEvent).toHaveBeenCalledWith('preview_template', {
@@ -96,13 +99,19 @@ describe('LearnDifyItem', () => {
})
})
it('should run the card action when Enter is pressed', () => {
it('should run the card action when Enter is pressed', async () => {
const onTry = vi.fn()
const app = createApp()
const user = userEvent.setup()
render(<LearnDifyItem canCreate={false} item={app} onTry={onTry} />)
fireEvent.keyDown(screen.getByRole('button', { name: 'Learn Dify App' }), { key: 'Enter' })
const card = screen.getByRole('button', { name: 'Learn Dify App' })
expect(card).toHaveAttribute('type', 'button')
expect(card).toHaveAccessibleDescription('Learn Dify description')
card.focus()
await user.keyboard('{Enter}')
expect(onTry).toHaveBeenCalledWith({ appId: app.app_id, app })
})
@@ -119,7 +119,7 @@ const LearnDifyContent = ({
{onHide && (
<button
type="button"
className="flex size-8 shrink-0 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
className="flex size-8 shrink-0 touch-manipulation items-center justify-center rounded-lg text-text-tertiary outline-hidden transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
aria-label={t(($) => $['learnDify.hide'], { ns: 'explore' })}
onClick={handleHide}
>
+17 -15
View File
@@ -1,6 +1,5 @@
'use client'
import type { KeyboardEvent } from 'react'
import type { App } from '@/models/explore'
import type { TryAppSelection } from '@/types/try-app'
import { cn } from '@langgenius/dify-ui/cn'
@@ -22,6 +21,8 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
...systemFeaturesQueryOptions(),
select: ({ deployment_edition }) => deployment_edition,
})
const appNameId = React.useId()
const appDescriptionId = React.useId()
const appBasicInfo = item.app
const canViewApp = deploymentEdition === 'CLOUD'
const canShowCreate = canCreate && !!onCreate
@@ -45,25 +46,23 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
if (canShowCreate) onCreate?.(item)
}
const handleCardKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
handleCardClick()
}
return (
<article
className={cn(
'relative flex min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 shadow-xs',
'relative flex min-w-0 flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg px-4 pt-4 pb-4 text-left shadow-xs',
isClickable && 'cursor-pointer',
)}
role={isClickable ? 'button' : undefined}
tabIndex={isClickable ? 0 : undefined}
aria-label={isClickable ? appBasicInfo.name : undefined}
onClick={isClickable ? handleCardClick : undefined}
onKeyDown={isClickable ? handleCardKeyDown : undefined}
>
{isClickable && (
<button
type="button"
className="absolute inset-0 z-10 cursor-pointer touch-manipulation appearance-none rounded-xl border-0 bg-transparent p-0 outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
aria-labelledby={appNameId}
aria-describedby={item.description ? appDescriptionId : undefined}
onClick={handleCardClick}
/>
)}
<div className="flex flex-col items-start gap-2 pb-1">
<AppIcon
size="large"
@@ -72,11 +71,14 @@ const LearnDifyItem = ({ canCreate, item, onCreate, onTry }: LearnDifyItemProps)
background={appBasicInfo.icon_background}
imageUrl={appBasicInfo.icon_url}
/>
<h3 className="w-full truncate system-md-semibold text-text-secondary">
<h3 id={appNameId} className="w-full truncate system-md-semibold text-text-secondary">
{appBasicInfo.name}
</h3>
</div>
<p className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary">
<p
id={appDescriptionId}
className="line-clamp-2 min-h-8 system-xs-regular text-text-tertiary"
>
{item.description}
</p>
</article>