mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
fix(skill): improve accessibility for file tree and tabs
- Convert div with onClick to proper button elements for keyboard access - Add focus-visible ring styles to all interactive elements - Add ARIA attributes (role, aria-selected, aria-expanded) to tree nodes - Add keyboard navigation (Enter/Space) support to tree items - Mark decorative icons with aria-hidden="true" - Add missing i18n keys for accessibility labels - Fix typography: use ellipsis character (…) instead of three dots
This commit is contained in:
@ -42,39 +42,48 @@ const EditorTabItem: FC<EditorTabItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex shrink-0 cursor-pointer items-center gap-1.5 border-r border-components-panel-border-subtle px-2.5 pb-2 pt-2.5',
|
||||
'group relative flex shrink-0 items-center border-r border-components-panel-border-subtle',
|
||||
isActive ? 'bg-components-panel-bg' : 'bg-transparent hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<div className="relative flex size-5 shrink-0 items-center justify-center">
|
||||
<FileTypeIcon type={iconType as FileAppearanceType} size="sm" />
|
||||
{isDirty && (
|
||||
<span className="absolute -bottom-px -right-px size-[7px] rounded-full border border-white bg-text-warning-secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'max-w-40 truncate text-[13px] font-normal leading-4',
|
||||
isActive
|
||||
? 'text-text-primary'
|
||||
: 'text-text-tertiary',
|
||||
'flex items-center gap-1.5 px-2.5 pb-2 pt-2.5',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
<div className="relative flex size-5 shrink-0 items-center justify-center">
|
||||
<FileTypeIcon type={iconType as FileAppearanceType} size="sm" />
|
||||
{isDirty && (
|
||||
<span className="absolute -bottom-px -right-px size-[7px] rounded-full border border-white bg-text-warning-secondary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
'max-w-40 truncate text-[13px] font-normal leading-4',
|
||||
isActive
|
||||
? 'text-text-primary'
|
||||
: 'text-text-tertiary',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-0.5 flex size-4 items-center justify-center rounded-[6px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-100',
|
||||
'mr-1 flex size-4 items-center justify-center rounded-[6px] text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
|
||||
isActive ? 'opacity-100' : 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100',
|
||||
)}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
onClick={handleClose}
|
||||
>
|
||||
<RiCloseLine className="size-4" />
|
||||
<RiCloseLine className="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -28,9 +28,10 @@ const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disable
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 text-text-tertiary" />
|
||||
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
@ -84,10 +85,11 @@ const FileItemMenu: FC<FileItemMenuProps> = ({
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-destructive-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
'group',
|
||||
)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" />
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
|
||||
{t('skillSidebar.menu.delete')}
|
||||
</span>
|
||||
|
||||
@ -32,9 +32,10 @@ const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disable
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 text-text-tertiary" />
|
||||
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
@ -142,10 +143,11 @@ const FileOperationsMenu: FC<FileOperationsMenuProps> = ({
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-destructive-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
'group',
|
||||
)}
|
||||
>
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" />
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary group-hover:text-text-destructive" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary group-hover:text-text-destructive">
|
||||
{t('skillSidebar.menu.delete')}
|
||||
</span>
|
||||
|
||||
@ -40,9 +40,10 @@ const MenuItem: React.FC<MenuItemProps> = ({ icon: Icon, label, onClick, disable
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2',
|
||||
'hover:bg-state-base-hover disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4 text-text-tertiary" />
|
||||
<Icon className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
<span className="system-sm-regular text-text-secondary">
|
||||
{label}
|
||||
</span>
|
||||
@ -99,7 +100,7 @@ const SidebarSearchAdd: FC = () => {
|
||||
className={cn('!h-8 !w-8 !px-0')}
|
||||
aria-label={t('operation.add', { ns: 'common' })}
|
||||
>
|
||||
<RiAddLine className="h-4 w-4" />
|
||||
<RiAddLine className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[30]">
|
||||
|
||||
@ -6,6 +6,7 @@ import type { FileAppearanceType } from '@/app/components/base/file-uploader/typ
|
||||
import { RiFolderLine, RiFolderOpenLine, RiMoreFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FileTypeIcon from '@/app/components/base/file-uploader/file-type-icon'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
@ -19,6 +20,7 @@ import { useSkillEditorStore, useSkillEditorStoreApi } from './store'
|
||||
import { getFileIconType } from './utils/file-utils'
|
||||
|
||||
const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>) => {
|
||||
const { t } = useTranslation('workflow')
|
||||
const isFolder = node.data.node_type === 'folder'
|
||||
const isSelected = node.isSelected
|
||||
const isDirty = useSkillEditorStore(s => s.dirtyContents.has(node.data.id))
|
||||
@ -62,18 +64,34 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
setShowDropdown(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (isFolder)
|
||||
node.toggle()
|
||||
else
|
||||
node.activate()
|
||||
}
|
||||
}, [isFolder, node])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dragHandle}
|
||||
style={style}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
aria-selected={isSelected}
|
||||
aria-expanded={isFolder ? node.isOpen : undefined}
|
||||
className={cn(
|
||||
'group flex h-6 cursor-pointer items-center gap-2 rounded-md px-2',
|
||||
'hover:bg-state-base-hover',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
|
||||
isSelected && 'bg-state-base-active',
|
||||
hasContextMenu && !isSelected && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<div className="flex size-5 shrink-0 items-center justify-center">
|
||||
@ -81,12 +99,17 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleToggle}
|
||||
className="flex size-full items-center justify-center"
|
||||
aria-label={t('skillSidebar.toggleFolder')}
|
||||
className={cn(
|
||||
'flex size-full items-center justify-center rounded',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
|
||||
)}
|
||||
>
|
||||
{node.isOpen
|
||||
? <RiFolderOpenLine className="size-4 text-text-accent" />
|
||||
: <RiFolderLine className="size-4 text-text-secondary" />}
|
||||
? <RiFolderOpenLine className="size-4 text-text-accent" aria-hidden="true" />
|
||||
: <RiFolderLine className="size-4 text-text-secondary" aria-hidden="true" />}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
@ -119,16 +142,18 @@ const TreeNode = ({ node, style, dragHandle }: NodeRendererProps<TreeNodeData>)
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={handleMoreClick}
|
||||
className={cn(
|
||||
'flex size-5 shrink-0 items-center justify-center rounded',
|
||||
'hover:bg-state-base-hover-alt',
|
||||
'invisible group-hover:visible',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-active',
|
||||
'invisible focus-visible:visible group-hover:visible',
|
||||
showDropdown && 'visible',
|
||||
)}
|
||||
aria-label="File operations"
|
||||
aria-label={t('skillSidebar.menu.moreActions')}
|
||||
>
|
||||
<RiMoreFill className="size-4 text-text-tertiary" />
|
||||
<RiMoreFill className="size-4 text-text-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[100]">
|
||||
|
||||
Reference in New Issue
Block a user