feat(dify-ui): file tree (#37235)

This commit is contained in:
yyh 2026-06-09 18:41:09 +08:00 committed by GitHub
parent 19d2a4d7a0
commit a823649934
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 933 additions and 12 deletions

View File

@ -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:

View File

@ -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"

View 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')
})
})

View 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 />,
}

View 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),
})
}