From a8236499343f34cdb665b80c90296f221bc16d60 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Tue, 9 Jun 2026 18:41:09 +0800 Subject: [PATCH] feat(dify-ui): file tree (#37235) --- packages/dify-ui/README.md | 24 +- packages/dify-ui/package.json | 4 + .../src/file-tree/__tests__/index.spec.tsx | 193 +++++++++ .../dify-ui/src/file-tree/index.stories.tsx | 346 ++++++++++++++++ packages/dify-ui/src/file-tree/index.tsx | 378 ++++++++++++++++++ 5 files changed, 933 insertions(+), 12 deletions(-) create mode 100644 packages/dify-ui/src/file-tree/__tests__/index.spec.tsx create mode 100644 packages/dify-ui/src/file-tree/index.stories.tsx create mode 100644 packages/dify-ui/src/file-tree/index.tsx diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index 157f6eb752..36e5585538 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -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: diff --git a/packages/dify-ui/package.json b/packages/dify-ui/package.json index 3a9995f867..dcc19c6750 100644 --- a/packages/dify-ui/package.json +++ b/packages/dify-ui/package.json @@ -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" diff --git a/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx b/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx new file mode 100644 index 0000000000..1734189df1 --- /dev/null +++ b/packages/dify-ui/src/file-tree/__tests__/index.spec.tsx @@ -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 ( + + + + + + src + + + + + + components + + + onPreview('button')}> + + button.tsx + + onPreview('readme')}> + + README.md + + + + onPreview('index')}> + + index.ts + + + + onPreview('package')}> + + package.json + + + + ) +} + +describe('FileTree', () => { + it('renders a labelled disclosure list instead of an ARIA treeview', async () => { + const screen = await render() + 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() + + 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( + + + {iconTypes.map(([type]) => ( + + + {type} + + ))} + + , + ) + + 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() + 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() + + 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( + + + onPreview('disabled')}> + + disabled.txt + + + , + ) + + 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( + + + + + + locked + + + + + nested.txt + + + + + , + ) + 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') + }) +}) diff --git a/packages/dify-ui/src/file-tree/index.stories.tsx b/packages/dify-ui/src/file-tree/index.stories.tsx new file mode 100644 index 0000000000..2f00b36f49 --- /dev/null +++ b/packages/dify-ui/src/file-tree/index.stories.tsx @@ -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 + +export default meta +type Story = StoryObj + +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 ( + + + + {node.name} + + + + + + ) + } + + return ( + onPreview(node.id)} + > + + {node.name} + {node.meta && {node.meta}} + {node.badge && {node.badge}} + + ) + }) +} + +function ComposedFileTree() { + const [selectedItemId, setSelectedItemId] = useState('button') + + return ( + + + + + + src + + + + + + components + + + setSelectedItemId('button')}> + + button.tsx + + setSelectedItemId('dialog')}> + + dialog.tsx + + setSelectedItemId('readme')}> + + README.md + + setSelectedItemId('config')}> + + config.json + + + + setSelectedItemId('index')}> + + index.ts + + + + setSelectedItemId('hero')}> + + hero.png + + setSelectedItemId('license')}> + + LICENSE + root + + + + ) +} + +function DataDrivenFileTree() { + const [selectedItemId, setSelectedItemId] = useState('app-components-file-tree') + + return ( + + + + + + ) +} + +function IconGallery() { + const iconTypes = [ + 'folder', + 'file', + 'markdown', + 'json', + 'image', + 'code', + 'database', + 'text', + 'pdf', + 'table', + 'archive', + ] as const + + return ( + + + {iconTypes.map(type => ( + type === 'folder' + ? ( + + + + {type} + + + + ) + : ( + + + {type} + + ) + ))} + + + ) +} + +function StateFrame({ + label, + children, +}: { + label: string + children: ReactNode +}) { + return ( +
+
{label}
+ + + {children} + + +
+ ) +} + +function VisualStates() { + return ( +
+ + + + default.txt + + + + + + active.md + + + + + + disabled.json + + + + + + + disabled-folder + + + + + nested.ts + + + + + + + + + closed-folder + + + + + nested.ts + + + + + + + + + open-folder + + + + + nested.ts + + + + + + + + very-long-file-name-that-should-truncate-without-shifting-layout.txt + preview + + +
+ ) +} + +export const Default: Story = { + render: () => , +} + +export const DataDriven: Story = { + render: () => , +} + +export const Icons: Story = { + render: () => , +} + +export const States: Story = { + render: () => , +} diff --git a/packages/dify-ui/src/file-tree/index.tsx b/packages/dify-ui/src/file-tree/index.tsx new file mode 100644 index 0000000000..1b56ae8fac --- /dev/null +++ b/packages/dify-ui/src/file-tree/index.tsx @@ -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) => ( + + )) +} + +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: ( + + {children} + + ), + } + + 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 + & { + render?: BaseCollapsible.Root.Props['render'] + } + +export function FileTreeFolder({ + render =
  • , + className, + ...props +}: FileTreeFolderProps) { + return ( + + ) +} + +export type FileTreeFolderTriggerProps + = Omit + & { + className?: string + level?: number + } + +export function FileTreeFolderTrigger({ + className, + children, + disabled, + level: levelProp, + ...props +}: FileTreeFolderTriggerProps) { + const contextLevel = useFileTreeLevel() + const level = levelProp ?? contextLevel + + return ( + + {renderGuides(level)} +
    + {children} +
    +
    + ) +} + +export type FileTreeFolderPanelProps + = Omit + & { + render?: BaseCollapsible.Panel.Props['render'] + } + +export function FileTreeFolderPanel({ + render =
      , + className, + children, + ...props +}: FileTreeFolderPanelProps) { + const level = useFileTreeLevel() + + return ( + + + {children} + + + ) +} + +export type FileTreeFileProps + = Omit, '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)} +
      + {children} +
      + + ), + } as useRender.ElementProps<'button'> + + const file = useRender({ + defaultTagName: 'button', + render, + state, + props: mergeProps<'button'>(defaultProps, props), + }) + + return
    • {file}
    • +} + +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, 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, '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' + ? ( + <> + + + + ) + : + )} + + ), + } + + 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), + }) +}