mirror of
https://github.com/langgenius/dify.git
synced 2026-03-13 19:17:43 +08:00
- 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
182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
'use client'
|
|
|
|
import type { NodeRendererProps } from 'react-arborist'
|
|
import type { TreeNodeData } from './type'
|
|
import type { FileAppearanceType } from '@/app/components/base/file-uploader/types'
|
|
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,
|
|
PortalToFollowElemContent,
|
|
PortalToFollowElemTrigger,
|
|
} from '@/app/components/base/portal-to-follow-elem'
|
|
import { cn } from '@/utils/classnames'
|
|
import FileNodeMenu from './file-node-menu'
|
|
import FolderNodeMenu from './folder-node-menu'
|
|
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))
|
|
const contextMenuNodeId = useSkillEditorStore(s => s.contextMenu?.nodeId)
|
|
const hasContextMenu = contextMenuNodeId === node.data.id
|
|
const storeApi = useSkillEditorStoreApi()
|
|
|
|
const [showDropdown, setShowDropdown] = useState(false)
|
|
|
|
const fileIconType = !isFolder ? getFileIconType(node.data.name) : null
|
|
|
|
const handleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
node.handleClick(e)
|
|
}
|
|
|
|
const handleDoubleClick = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
if (!isFolder)
|
|
node.activate()
|
|
}
|
|
|
|
const handleToggle = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
node.toggle()
|
|
}
|
|
|
|
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
storeApi.getState().setContextMenu({
|
|
top: e.clientY,
|
|
left: e.clientX,
|
|
nodeId: node.data.id,
|
|
})
|
|
}, [node.data.id, storeApi])
|
|
|
|
const handleMoreClick = useCallback((e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
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">
|
|
{isFolder
|
|
? (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
onClick={handleToggle}
|
|
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" aria-hidden="true" />
|
|
: <RiFolderLine className="size-4 text-text-secondary" aria-hidden="true" />}
|
|
</button>
|
|
)
|
|
: (
|
|
<div className="relative flex size-full items-center justify-center">
|
|
<FileTypeIcon type={fileIconType as FileAppearanceType} size="sm" />
|
|
{isDirty && (
|
|
<span className="absolute -bottom-px -right-px size-[7px] rounded-full border border-white bg-text-warning-secondary" />
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<span
|
|
className={cn(
|
|
'min-w-0 flex-1 truncate text-[13px] font-normal leading-4',
|
|
isSelected
|
|
? 'text-text-primary'
|
|
: 'text-text-secondary',
|
|
)}
|
|
>
|
|
{node.data.name}
|
|
</span>
|
|
|
|
<PortalToFollowElem
|
|
placement="bottom-start"
|
|
offset={4}
|
|
open={showDropdown}
|
|
onOpenChange={setShowDropdown}
|
|
>
|
|
<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',
|
|
'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={t('skillSidebar.menu.moreActions')}
|
|
>
|
|
<RiMoreFill className="size-4 text-text-tertiary" aria-hidden="true" />
|
|
</button>
|
|
</PortalToFollowElemTrigger>
|
|
<PortalToFollowElemContent className="z-[100]">
|
|
{isFolder
|
|
? (
|
|
<FolderNodeMenu
|
|
nodeId={node.data.id}
|
|
onClose={() => setShowDropdown(false)}
|
|
node={node}
|
|
/>
|
|
)
|
|
: (
|
|
<FileNodeMenu
|
|
nodeId={node.data.id}
|
|
onClose={() => setShowDropdown(false)}
|
|
node={node}
|
|
/>
|
|
)}
|
|
</PortalToFollowElemContent>
|
|
</PortalToFollowElem>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default React.memo(TreeNode)
|