mirror of
https://github.com/langgenius/dify.git
synced 2026-06-10 18:24:09 +08:00
feat(dify-ui): file tree (#37235)
This commit is contained in:
parent
19d2a4d7a0
commit
a823649934
@ -43,18 +43,18 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./pagination`, `./tabs` | Pagination for page navigation; Tabs for panels. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Controls | `./segmented-control` | SegmentedControl for mode, filter, and view selection. |
|
||||
| Display | `./kbd` | Keyboard input and shortcut keycap primitives. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./file-tree`, `./pagination`, `./tabs` | FileTree for preview-oriented file disclosure lists; Pagination for page navigation; Tabs for panels. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
|
||||
Utilities:
|
||||
|
||||
|
||||
@ -61,6 +61,10 @@
|
||||
"types": "./src/fieldset/index.tsx",
|
||||
"import": "./src/fieldset/index.tsx"
|
||||
},
|
||||
"./file-tree": {
|
||||
"types": "./src/file-tree/index.tsx",
|
||||
"import": "./src/file-tree/index.tsx"
|
||||
},
|
||||
"./form": {
|
||||
"types": "./src/form/index.tsx",
|
||||
"import": "./src/form/index.tsx"
|
||||
|
||||
193
packages/dify-ui/src/file-tree/__tests__/index.spec.tsx
Normal file
193
packages/dify-ui/src/file-tree/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
FileTreeFolderPanel,
|
||||
FileTreeFolderTrigger,
|
||||
FileTreeIcon,
|
||||
FileTreeLabel,
|
||||
FileTreeList,
|
||||
FileTreeRoot,
|
||||
} from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
|
||||
function TestFileTree({
|
||||
onPreview = vi.fn(),
|
||||
}: {
|
||||
onPreview?: (itemId: string) => void
|
||||
}) {
|
||||
return (
|
||||
<FileTreeRoot aria-label="Project files">
|
||||
<FileTreeList>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>src</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>components</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile selected onClick={() => onPreview('button')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>button.tsx</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile onClick={() => onPreview('readme')}>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel>README.md</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile onClick={() => onPreview('index')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>index.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile onClick={() => onPreview('package')}>
|
||||
<FileTreeIcon type="json" />
|
||||
<FileTreeLabel>package.json</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
describe('FileTree', () => {
|
||||
it('renders a labelled disclosure list instead of an ARIA treeview', async () => {
|
||||
const screen = await render(<TestFileTree />)
|
||||
const root = screen.getByLabelText('Project files')
|
||||
const src = screen.getByRole('button', { name: 'src' })
|
||||
const selectedFile = screen.getByRole('button', { name: 'button.tsx' })
|
||||
|
||||
await expect.element(root).not.toHaveAttribute('role', 'tree')
|
||||
await expect.element(src).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect.element(src).toHaveAttribute('aria-controls')
|
||||
await expect.element(src).not.toHaveAttribute('aria-current')
|
||||
await expect.element(src).not.toHaveAttribute('data-selected')
|
||||
await expect.element(selectedFile).toHaveAttribute('aria-current', 'true')
|
||||
await expect.element(selectedFile).toHaveAttribute('data-selected')
|
||||
})
|
||||
|
||||
it('uses Figma-aligned row, indentation, icon, and selected label styles', async () => {
|
||||
const screen = await render(<TestFileTree />)
|
||||
|
||||
await expect.element(screen.getByLabelText('Project files')).toHaveClass('gap-px', 'p-1')
|
||||
await expect.element(screen.getByRole('button', { name: 'button.tsx' })).toHaveClass('h-6', 'rounded-md', 'pl-2', 'pr-1.5', 'data-[selected]:bg-state-base-active')
|
||||
await expect.element(screen.getByText('button.tsx')).toHaveClass('group-data-[selected]/file-tree-row:system-sm-medium', 'group-data-[selected]/file-tree-row:text-text-primary')
|
||||
await expect.element(screen.getByText('README.md')).toHaveAttribute('data-label', 'README.md')
|
||||
await expect.element(screen.getByText('README.md')).toHaveClass('after:content-[attr(data-label)]')
|
||||
expect(screen.container.querySelector('.before\\:bottom-\\[-1px\\]')).toBeInTheDocument()
|
||||
expect(screen.container.querySelector('.i-ri-folder-open-line')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses Remix fill icons for each non-folder file type', async () => {
|
||||
const iconTypes = [
|
||||
['file', 'i-ri-file-3-fill'],
|
||||
['markdown', 'i-ri-markdown-fill'],
|
||||
['json', 'i-ri-braces-fill'],
|
||||
['image', 'i-ri-file-image-fill'],
|
||||
['code', 'i-ri-file-code-fill'],
|
||||
['database', 'i-ri-database-2-fill'],
|
||||
['text', 'i-ri-file-text-fill'],
|
||||
['pdf', 'i-ri-file-pdf-2-fill'],
|
||||
['table', 'i-ri-file-excel-fill'],
|
||||
['archive', 'i-ri-file-zip-fill'],
|
||||
] as const
|
||||
const screen = await render(
|
||||
<FileTreeRoot aria-label="Icon examples">
|
||||
<FileTreeList>
|
||||
{iconTypes.map(([type]) => (
|
||||
<FileTreeFile key={type}>
|
||||
<FileTreeIcon type={type} />
|
||||
<FileTreeLabel>{type}</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
))}
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>,
|
||||
)
|
||||
|
||||
for (const [, iconClassName] of iconTypes)
|
||||
expect(screen.container.querySelector(`.${iconClassName}`)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands folders with click and native button keyboard behavior', async () => {
|
||||
const screen = await render(<TestFileTree />)
|
||||
const src = screen.getByRole('button', { name: 'src' }).element() as HTMLElement
|
||||
|
||||
src.click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.container.textContent).not.toContain('components')
|
||||
|
||||
src.click()
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: 'src' })).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect.element(screen.getByRole('button', { name: 'components' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('activates file preview buttons without navigation semantics', async () => {
|
||||
const onPreview = vi.fn()
|
||||
const screen = await render(<TestFileTree onPreview={onPreview} />)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'README.md' }).element()).click()
|
||||
|
||||
expect(onPreview).toHaveBeenCalledWith('readme')
|
||||
await expect.element(screen.getByRole('button', { name: 'README.md' })).not.toHaveAttribute('href')
|
||||
})
|
||||
|
||||
it('does not activate disabled file buttons', async () => {
|
||||
const onPreview = vi.fn()
|
||||
const screen = await render(
|
||||
<FileTreeRoot aria-label="Disabled files">
|
||||
<FileTreeList>
|
||||
<FileTreeFile disabled onClick={() => onPreview('disabled')}>
|
||||
<FileTreeIcon type="file" />
|
||||
<FileTreeLabel>disabled.txt</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'disabled.txt' }).element()).click()
|
||||
|
||||
expect(onPreview).not.toHaveBeenCalled()
|
||||
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toBeDisabled()
|
||||
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toHaveAttribute('data-disabled')
|
||||
await expect.element(screen.getByRole('button', { name: 'disabled.txt' })).toHaveClass('data-disabled:cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('styles disabled folder triggers from the resolved collapsible state', async () => {
|
||||
const onOpenChange = vi.fn()
|
||||
const screen = await render(
|
||||
<FileTreeRoot aria-label="Disabled folders">
|
||||
<FileTreeList>
|
||||
<FileTreeFolder disabled defaultOpen onOpenChange={onOpenChange}>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>locked</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="file" />
|
||||
<FileTreeLabel>nested.txt</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>,
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: 'locked' })
|
||||
|
||||
asHTMLElement(trigger.element()).click()
|
||||
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
await expect.element(trigger).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect.element(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect.element(trigger).toHaveClass('aria-disabled:cursor-not-allowed')
|
||||
})
|
||||
})
|
||||
346
packages/dify-ui/src/file-tree/index.stories.tsx
Normal file
346
packages/dify-ui/src/file-tree/index.stories.tsx
Normal file
@ -0,0 +1,346 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { FileTreeIconType } from '.'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
FileTreeBadge,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
FileTreeFolderPanel,
|
||||
FileTreeFolderTrigger,
|
||||
FileTreeIcon,
|
||||
FileTreeLabel,
|
||||
FileTreeList,
|
||||
FileTreeMeta,
|
||||
FileTreeRoot,
|
||||
} from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/UI/FileTree',
|
||||
component: FileTreeRoot,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Composable file preview list built with Base UI Collapsible. Folders are disclosure buttons, files are preview buttons, and feature code owns data loading, routing, editing, drag-and-drop, and item actions.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof FileTreeRoot>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
type ExampleFileTreeNode = {
|
||||
id: string
|
||||
name: string
|
||||
icon: FileTreeIconType
|
||||
meta?: string
|
||||
badge?: string
|
||||
children?: ExampleFileTreeNode[]
|
||||
}
|
||||
|
||||
const fileTreeData: ExampleFileTreeNode[] = [
|
||||
{
|
||||
id: 'app',
|
||||
name: 'app',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{
|
||||
id: 'app-components',
|
||||
name: 'components',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{ id: 'app-components-file-tree', name: 'file-tree.tsx', icon: 'code' },
|
||||
{ id: 'app-components-readme', name: 'README.md', icon: 'markdown' },
|
||||
{ id: 'app-components-package', name: 'package.json', icon: 'json' },
|
||||
],
|
||||
},
|
||||
{ id: 'app-layout', name: 'layout.tsx', icon: 'code' },
|
||||
{ id: 'app-theme', name: 'theme.css', icon: 'code', meta: 'global' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'assets',
|
||||
name: 'assets',
|
||||
icon: 'folder',
|
||||
children: [
|
||||
{ id: 'assets-hero', name: 'hero.png', icon: 'image' },
|
||||
{ id: 'assets-export', name: 'export.zip', icon: 'archive', badge: '4 MB' },
|
||||
],
|
||||
},
|
||||
{ id: 'schema', name: 'schema.sqlite', icon: 'database' },
|
||||
]
|
||||
|
||||
function FileTreeNodeRows({
|
||||
nodes,
|
||||
selectedItemId,
|
||||
onPreview,
|
||||
}: {
|
||||
nodes: ExampleFileTreeNode[]
|
||||
selectedItemId: string | null
|
||||
onPreview: (itemId: string) => void
|
||||
}) {
|
||||
return nodes.map((node) => {
|
||||
if (node.children?.length) {
|
||||
return (
|
||||
<FileTreeFolder key={node.id} defaultOpen={node.id === 'app' || node.id === 'app-components'}>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>{node.name}</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeNodeRows
|
||||
nodes={node.children}
|
||||
selectedItemId={selectedItemId}
|
||||
onPreview={onPreview}
|
||||
/>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<FileTreeFile
|
||||
key={node.id}
|
||||
selected={selectedItemId === node.id}
|
||||
onClick={() => onPreview(node.id)}
|
||||
>
|
||||
<FileTreeIcon type={node.icon} />
|
||||
<FileTreeLabel>{node.name}</FileTreeLabel>
|
||||
{node.meta && <FileTreeMeta>{node.meta}</FileTreeMeta>}
|
||||
{node.badge && <FileTreeBadge>{node.badge}</FileTreeBadge>}
|
||||
</FileTreeFile>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function ComposedFileTree() {
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>('button')
|
||||
|
||||
return (
|
||||
<FileTreeRoot
|
||||
aria-label="Project files"
|
||||
className="w-80 rounded-lg border border-divider-subtle bg-background-default-subtle"
|
||||
>
|
||||
<FileTreeList>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>src</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>components</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile selected={selectedItemId === 'button'} onClick={() => setSelectedItemId('button')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>button.tsx</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'dialog'} onClick={() => setSelectedItemId('dialog')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>dialog.tsx</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'readme'} onClick={() => setSelectedItemId('readme')}>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel>README.md</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'config'} onClick={() => setSelectedItemId('config')}>
|
||||
<FileTreeIcon type="json" />
|
||||
<FileTreeLabel>config.json</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile selected={selectedItemId === 'index'} onClick={() => setSelectedItemId('index')}>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>index.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
<FileTreeFile selected={selectedItemId === 'hero'} onClick={() => setSelectedItemId('hero')}>
|
||||
<FileTreeIcon type="image" />
|
||||
<FileTreeLabel>hero.png</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
<FileTreeFile selected={selectedItemId === 'license'} onClick={() => setSelectedItemId('license')}>
|
||||
<FileTreeIcon type="text" />
|
||||
<FileTreeLabel>LICENSE</FileTreeLabel>
|
||||
<FileTreeMeta>root</FileTreeMeta>
|
||||
</FileTreeFile>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function DataDrivenFileTree() {
|
||||
const [selectedItemId, setSelectedItemId] = useState<string | null>('app-components-file-tree')
|
||||
|
||||
return (
|
||||
<FileTreeRoot
|
||||
aria-label="Data-driven project files"
|
||||
className="w-80 rounded-lg border border-divider-subtle bg-background-default-subtle"
|
||||
>
|
||||
<FileTreeList>
|
||||
<FileTreeNodeRows
|
||||
nodes={fileTreeData}
|
||||
selectedItemId={selectedItemId}
|
||||
onPreview={setSelectedItemId}
|
||||
/>
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function IconGallery() {
|
||||
const iconTypes = [
|
||||
'folder',
|
||||
'file',
|
||||
'markdown',
|
||||
'json',
|
||||
'image',
|
||||
'code',
|
||||
'database',
|
||||
'text',
|
||||
'pdf',
|
||||
'table',
|
||||
'archive',
|
||||
] as const
|
||||
|
||||
return (
|
||||
<FileTreeRoot aria-label="File icon examples" className="w-64 rounded-lg border border-divider-subtle bg-background-default-subtle">
|
||||
<FileTreeList>
|
||||
{iconTypes.map(type => (
|
||||
type === 'folder'
|
||||
? (
|
||||
<FileTreeFolder key={type}>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type={type} />
|
||||
<FileTreeLabel>{type}</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel />
|
||||
</FileTreeFolder>
|
||||
)
|
||||
: (
|
||||
<FileTreeFile key={type}>
|
||||
<FileTreeIcon type={type} />
|
||||
<FileTreeLabel>{type}</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
)
|
||||
))}
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
)
|
||||
}
|
||||
|
||||
function StateFrame({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="w-80 min-w-0 space-y-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{label}</div>
|
||||
<FileTreeRoot aria-label={label} className="rounded-lg border border-divider-subtle bg-background-default-subtle">
|
||||
<FileTreeList>
|
||||
{children}
|
||||
</FileTreeList>
|
||||
</FileTreeRoot>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VisualStates() {
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<StateFrame label="Default file">
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="file" />
|
||||
<FileTreeLabel>default.txt</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
<StateFrame label="Selected file">
|
||||
<FileTreeFile selected>
|
||||
<FileTreeIcon type="markdown" />
|
||||
<FileTreeLabel>active.md</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
<StateFrame label="Disabled file">
|
||||
<FileTreeFile disabled>
|
||||
<FileTreeIcon type="json" />
|
||||
<FileTreeLabel>disabled.json</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
<StateFrame label="Disabled folder">
|
||||
<FileTreeFolder disabled>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>disabled-folder</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>nested.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</StateFrame>
|
||||
<StateFrame label="Closed folder">
|
||||
<FileTreeFolder>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>closed-folder</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>nested.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</StateFrame>
|
||||
<StateFrame label="Open folder">
|
||||
<FileTreeFolder defaultOpen>
|
||||
<FileTreeFolderTrigger>
|
||||
<FileTreeIcon type="folder" />
|
||||
<FileTreeLabel>open-folder</FileTreeLabel>
|
||||
</FileTreeFolderTrigger>
|
||||
<FileTreeFolderPanel>
|
||||
<FileTreeFile>
|
||||
<FileTreeIcon type="code" />
|
||||
<FileTreeLabel>nested.ts</FileTreeLabel>
|
||||
</FileTreeFile>
|
||||
</FileTreeFolderPanel>
|
||||
</FileTreeFolder>
|
||||
</StateFrame>
|
||||
<StateFrame label="Long label">
|
||||
<FileTreeFile selected>
|
||||
<FileTreeIcon type="text" />
|
||||
<FileTreeLabel>very-long-file-name-that-should-truncate-without-shifting-layout.txt</FileTreeLabel>
|
||||
<FileTreeMeta>preview</FileTreeMeta>
|
||||
</FileTreeFile>
|
||||
</StateFrame>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <ComposedFileTree />,
|
||||
}
|
||||
|
||||
export const DataDriven: Story = {
|
||||
render: () => <DataDrivenFileTree />,
|
||||
}
|
||||
|
||||
export const Icons: Story = {
|
||||
render: () => <IconGallery />,
|
||||
}
|
||||
|
||||
export const States: Story = {
|
||||
render: () => <VisualStates />,
|
||||
}
|
||||
378
packages/dify-ui/src/file-tree/index.tsx
Normal file
378
packages/dify-ui/src/file-tree/index.tsx
Normal file
@ -0,0 +1,378 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Collapsible as BaseCollapsible } from '@base-ui/react/collapsible'
|
||||
import { mergeProps } from '@base-ui/react/merge-props'
|
||||
import { useRender } from '@base-ui/react/use-render'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
} from 'react'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const FileTreeLevelContext = createContext(1)
|
||||
|
||||
function useFileTreeLevel() {
|
||||
return useContext(FileTreeLevelContext)
|
||||
}
|
||||
|
||||
function getLabelText(children: ReactNode) {
|
||||
return typeof children === 'string' || typeof children === 'number'
|
||||
? String(children)
|
||||
: undefined
|
||||
}
|
||||
|
||||
function renderGuides(level: number) {
|
||||
return Array.from({ length: Math.max(level - 1, 0) }, (_, index) => (
|
||||
<FileTreeGuide key={index} />
|
||||
))
|
||||
}
|
||||
|
||||
type FileTreeRowState = {
|
||||
selected: boolean
|
||||
disabled: boolean
|
||||
level: number
|
||||
}
|
||||
|
||||
function fileTreeRowClassName({
|
||||
className,
|
||||
}: {
|
||||
className?: string
|
||||
}) {
|
||||
return cn(
|
||||
'group/file-tree-row relative flex h-6 w-full min-w-0 cursor-pointer items-center rounded-md pl-2 pr-1.5 text-left outline-hidden select-none',
|
||||
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-state-accent-solid',
|
||||
'data-[selected]:bg-state-base-active',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-transparent',
|
||||
'aria-disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:hover:bg-transparent',
|
||||
className,
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeRootProps = useRender.ComponentProps<'section'>
|
||||
|
||||
export function FileTreeRoot({
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeRootProps) {
|
||||
const defaultProps: useRender.ElementProps<'section'> = {
|
||||
className: cn('flex min-w-0 flex-col gap-px p-1', className),
|
||||
children: (
|
||||
<FileTreeLevelContext.Provider value={1}>
|
||||
{children}
|
||||
</FileTreeLevelContext.Provider>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'section',
|
||||
render,
|
||||
props: mergeProps<'section'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeListProps = useRender.ComponentProps<'ul'>
|
||||
|
||||
export function FileTreeList({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeListProps) {
|
||||
const defaultProps: useRender.ElementProps<'ul'> = {
|
||||
className: cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'ul',
|
||||
render,
|
||||
props: mergeProps<'ul'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeFolderProps
|
||||
= Omit<BaseCollapsible.Root.Props, 'render'>
|
||||
& {
|
||||
render?: BaseCollapsible.Root.Props['render']
|
||||
}
|
||||
|
||||
export function FileTreeFolder({
|
||||
render = <li />,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeFolderProps) {
|
||||
return (
|
||||
<BaseCollapsible.Root
|
||||
render={render}
|
||||
className={cn('min-w-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeFolderTriggerProps
|
||||
= Omit<BaseCollapsible.Trigger.Props, 'className'>
|
||||
& {
|
||||
className?: string
|
||||
level?: number
|
||||
}
|
||||
|
||||
export function FileTreeFolderTrigger({
|
||||
className,
|
||||
children,
|
||||
disabled,
|
||||
level: levelProp,
|
||||
...props
|
||||
}: FileTreeFolderTriggerProps) {
|
||||
const contextLevel = useFileTreeLevel()
|
||||
const level = levelProp ?? contextLevel
|
||||
|
||||
return (
|
||||
<BaseCollapsible.Trigger
|
||||
className={fileTreeRowClassName({ className })}
|
||||
disabled={disabled}
|
||||
data-disabled={disabled || undefined}
|
||||
{...props}
|
||||
>
|
||||
{renderGuides(level)}
|
||||
<div className="flex min-w-0 flex-[1_0_0] items-center py-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</BaseCollapsible.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeFolderPanelProps
|
||||
= Omit<BaseCollapsible.Panel.Props, 'render'>
|
||||
& {
|
||||
render?: BaseCollapsible.Panel.Props['render']
|
||||
}
|
||||
|
||||
export function FileTreeFolderPanel({
|
||||
render = <ul />,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFolderPanelProps) {
|
||||
const level = useFileTreeLevel()
|
||||
|
||||
return (
|
||||
<BaseCollapsible.Panel
|
||||
render={render}
|
||||
className={cn('m-0 flex min-w-0 list-none flex-col gap-px p-0', className)}
|
||||
{...props}
|
||||
>
|
||||
<FileTreeLevelContext.Provider value={level + 1}>
|
||||
{children}
|
||||
</FileTreeLevelContext.Provider>
|
||||
</BaseCollapsible.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export type FileTreeFileProps
|
||||
= Omit<useRender.ComponentProps<'button', FileTreeRowState>, 'type'>
|
||||
& {
|
||||
level?: number
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
export function FileTreeFile({
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
level: levelProp,
|
||||
selected = false,
|
||||
...props
|
||||
}: FileTreeFileProps) {
|
||||
const contextLevel = useFileTreeLevel()
|
||||
const level = levelProp ?? contextLevel
|
||||
const state: FileTreeRowState = {
|
||||
selected,
|
||||
disabled,
|
||||
level,
|
||||
}
|
||||
const defaultProps = {
|
||||
'type': 'button',
|
||||
'disabled': disabled,
|
||||
'data-selected': selected || undefined,
|
||||
'data-disabled': disabled || undefined,
|
||||
'aria-current': selected ? 'true' : undefined,
|
||||
'className': fileTreeRowClassName({ className }),
|
||||
'children': (
|
||||
<>
|
||||
{renderGuides(level)}
|
||||
<div className="flex min-w-0 flex-[1_0_0] items-center py-0.5">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
} as useRender.ElementProps<'button'>
|
||||
|
||||
const file = useRender({
|
||||
defaultTagName: 'button',
|
||||
render,
|
||||
state,
|
||||
props: mergeProps<'button'>(defaultProps, props),
|
||||
})
|
||||
|
||||
return <li className="min-w-0">{file}</li>
|
||||
}
|
||||
|
||||
export type FileTreeGuideProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function FileTreeGuide({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeGuideProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
'aria-hidden': true,
|
||||
'className': cn(
|
||||
'relative h-6 w-5 shrink-0 before:absolute before:bottom-[-1px] before:left-1/2 before:top-0 before:w-px before:-translate-x-1/2 before:bg-divider-subtle',
|
||||
className,
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeIconType
|
||||
= 'folder'
|
||||
| 'file'
|
||||
| 'markdown'
|
||||
| 'json'
|
||||
| 'image'
|
||||
| 'code'
|
||||
| 'database'
|
||||
| 'text'
|
||||
| 'pdf'
|
||||
| 'table'
|
||||
| 'archive'
|
||||
|
||||
const fileTreeIconClassNames: Record<Exclude<FileTreeIconType, 'folder'>, string> = {
|
||||
file: 'i-ri-file-3-fill text-[#A4AABF]',
|
||||
markdown: 'i-ri-markdown-fill text-[#309BEC]',
|
||||
json: 'i-ri-braces-fill text-[#A4AABF]',
|
||||
image: 'i-ri-file-image-fill text-[#00B2EA]',
|
||||
code: 'i-ri-file-code-fill text-[#A4AABF]',
|
||||
database: 'i-ri-database-2-fill text-[#A4AABF]',
|
||||
text: 'i-ri-file-text-fill text-[#6F8BB5]',
|
||||
pdf: 'i-ri-file-pdf-2-fill text-[#EA3434]',
|
||||
table: 'i-ri-file-excel-fill text-[#01AC49]',
|
||||
archive: 'i-ri-file-zip-fill text-[#A4AABF]',
|
||||
}
|
||||
|
||||
export type FileTreeIconProps
|
||||
= Omit<useRender.ComponentProps<'span'>, 'children'>
|
||||
& {
|
||||
type?: FileTreeIconType
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function FileTreeIcon({
|
||||
type = 'file',
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeIconProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
'aria-hidden': true,
|
||||
'className': cn('relative flex size-5 shrink-0 items-center justify-center text-text-secondary', className),
|
||||
'children': (
|
||||
<>
|
||||
{children ?? (
|
||||
type === 'folder'
|
||||
? (
|
||||
<>
|
||||
<span className="size-4 i-ri-folder-line group-data-panel-open/file-tree-row:hidden" />
|
||||
<span className="hidden size-4 text-text-accent i-ri-folder-open-line group-data-panel-open/file-tree-row:block" />
|
||||
</>
|
||||
)
|
||||
: <span className={cn('size-4', fileTreeIconClassNames[type])} />
|
||||
)}
|
||||
</>
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeLabelProps = useRender.ComponentProps<'span'>
|
||||
type FileTreeLabelElementProps = useRender.ElementProps<'span'> & {
|
||||
'data-label'?: string
|
||||
}
|
||||
|
||||
export function FileTreeLabel({
|
||||
render,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeLabelProps) {
|
||||
const labelText = getLabelText(children)
|
||||
const defaultProps = {
|
||||
'data-label': labelText,
|
||||
'className': cn(
|
||||
'min-w-0 truncate rounded-[5px] px-1 py-0.5',
|
||||
labelText && 'after:invisible after:block after:h-0 after:overflow-hidden after:system-sm-medium after:content-[attr(data-label)]',
|
||||
'system-sm-regular text-text-secondary group-data-[selected]/file-tree-row:system-sm-medium group-data-[selected]/file-tree-row:text-text-primary',
|
||||
className,
|
||||
),
|
||||
children,
|
||||
} satisfies FileTreeLabelElementProps
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeMetaProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function FileTreeMeta({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeMetaProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
className: cn('min-w-0 shrink truncate system-xs-regular text-text-tertiary', className),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export type FileTreeBadgeProps = useRender.ComponentProps<'span'>
|
||||
|
||||
export function FileTreeBadge({
|
||||
render,
|
||||
className,
|
||||
...props
|
||||
}: FileTreeBadgeProps) {
|
||||
const defaultProps: useRender.ElementProps<'span'> = {
|
||||
className: cn(
|
||||
'ml-1 inline-flex min-w-4 shrink-0 items-center justify-center rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary',
|
||||
className,
|
||||
),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: 'span',
|
||||
render,
|
||||
props: mergeProps<'span'>(defaultProps, props),
|
||||
})
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user