跳到主要内容

Tree

  • 组件说明:Folders 风格的树形导航 Pattern(文件夹 / 分组列表场景)。API 习惯参考 antd Tree。文档归类于 Patterns;实现位于 packages/design/src/components/Tree
  • 交互特征:点击任意未禁用行会选中。展开 / 折叠由左侧 chevron 触发(chevron 在 row hover / focus-within 时淡入显示)。键盘:Enter / Space 选中;ArrowLeft / ArrowRight 折叠 / 展开。可选 HTML5 拖拽(before / inside / after 三档、防成环、禁用行不可作为投放目标、折叠 folder 悬停自动展开)。
  • 实现约定:Tree 视觉由 packages/design/src/components/Tree 直接接管,没有 ui/ primitive,样式全部基于 design token 组合。
  • Figma 规范

基础用法

结果
Loading...
实时编辑器
<Tree
  defaultExpandedKeys={['projects']}
  treeData={[
    { key: 'all', title: 'All Recordings' },
    {
      key: 'projects',
      title: 'Projects',
      children: [
        { key: 'projects/web', title: 'Web Refactor' },
        { key: 'projects/mobile', title: 'Mobile App' },
      ],
    },
    { key: 'starred', title: 'Starred' },
    { key: 'trash', title: 'Trash', disabled: true },
  ]}
/>

Tree 不再提供单独的"计数 / 尾部信息"插槽——把计数等附属内容直接拼到 title 里(例如 <span>Projects <span className="text-(--Labels-Tertiary)">(99)</span></span>)。

状态

四种交互状态:Default / Hover / Selected / Disabled。按 Figma 规范,Selected 默认与 hover 共用背景色(--Grays-Gray-1);需要区分选中色时传入 selectedColor

结果
Loading...
实时编辑器
<Tree
  defaultSelectedKeys={['selected']}
  treeData={[
    { key: 'default', title: '默认项 — 鼠标悬浮试试' },
    { key: 'selected', title: '选中项' },
    { key: 'disabled', title: '禁用项', disabled: true },
  ]}
/>

自定义 selected 颜色

TreeselectedColor 可覆盖选中行背景。接受任意 CSS color 或 var() 表达式,通常传 design token:

结果
Loading...
实时编辑器
<Tree
  defaultSelectedKeys={['highlighted']}
  selectedColor="color-mix(in oklab, var(--Labels-Primary) 8%, transparent)"
  treeData={[
    { key: 'default', title: '默认项' },
    { key: 'highlighted', title: '使用自定义色的选中项' },
    { key: 'another', title: '其他项' },
  ]}
/>

自定义节点图标

每个 TreeDataNode 可通过 icon 传入任意 ReactNode,渲染在单一 20×20 槽位。建议尺寸 size-5(20px),颜色 text-(--Labels-Tertiary)。在 Tree 上设置 showIcon={false} 可关闭整棵树的节点图标。

展开 chevron 与节点 icon 在 folder 行共享同一槽位:默认展示 icon,行 hover / focus-within 时 chevron 以 opacity 渐变淡入覆盖 icon。叶子节点和 disabled folder 始终展示 icon。

icon 还可传函数 ({ expanded, hover }) => ReactNode,典型用法是按 expanded 切换"文件夹关 / 开"图标:

结果
Loading...
实时编辑器
<Tree
  defaultExpandedKeys={['root']}
  treeData={[
    {
      key: 'root',
      title: '工作区',
      icon: ({ expanded }) => (
        <svg className="size-5 text-(--Labels-Tertiary)" viewBox="0 0 20 20" fill="none" aria-hidden>
          {expanded ? (
            <path
              d="M2.5 6.5H8L9.5 4.5H17.5V15.5C17.5 16.0523 17.0523 16.5 16.5 16.5H3.5C2.94772 16.5 2.5 16.0523 2.5 15.5V6.5Z"
              stroke="currentColor"
              strokeWidth="1.2"
              strokeLinejoin="round"
            />
          ) : (
            <path
              d="M2.5 5.5C2.5 4.94772 2.94772 4.5 3.5 4.5H8L9.5 6.5H16.5C17.0523 6.5 17.5 6.94772 17.5 7.5V15.5C17.5 16.0523 17.0523 16.5 16.5 16.5H3.5C2.94772 16.5 2.5 16.0523 2.5 15.5V5.5Z"
              stroke="currentColor"
              strokeWidth="1.2"
              strokeLinejoin="round"
            />
          )}
        </svg>
      ),
      children: [
        {
          key: 'root/docs',
          title: '说明文档',
          icon: <Plus className="size-5 text-(--Labels-Tertiary)" aria-hidden />,
        },
        {
          key: 'root/api',
          title: '接口说明',
          icon: (
            <svg className="size-5 text-(--Labels-Tertiary)" viewBox="0 0 20 20" fill="none" aria-hidden>
              <path
                d="M5 4.5H15V15.5H5V4.5Z"
                stroke="currentColor"
                strokeWidth="1.2"
                strokeLinejoin="round"
              />
              <path d="M7 7.5H13M7 10H11M7 12.5H12" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
            </svg>
          ),
        },
      ],
    },
  ]}
/>

鼠标悬浮上面任意 folder 行,可以看到 chevron 在 folder 图标上方淡入。

Hover Actions

TreeDataNode.actions 用于行末尾的 hover-only 操作位(对应 Figma "Hover - More" 变体),仅在 hover / focus-within 或浮层打开时显示,通过内部 ml-auto 始终贴近行右端。actions 内部的点击不会冒泡触发节点的 onSelect / onExpand。disabled 行不会显示 actions。

当 action 会打开 DropdownMenu / Dialog 等浮层时,请使用函数形态 (state) => ReactNode,并把浮层的 onOpenChange 接到 state.setActionsOpen。这样浮层打开期间行会保持 active 视觉(背景、actions、chevron 与 hover 一致),避免焦点跳到 portal 之后行立即回到 idle 的反直觉表现。

行的 selection 与 actions 是相互独立的——点击 actions 内按钮不会触发选中。setActionsOpen 只控制浮层打开期间的视觉延续。

打开下拉菜单

把 "more" 触发按钮包在 DropdownMenu 中,传入声明式 items。把 onOpenChange 接到 setActionsOpen,菜单打开期间行保持 active 视觉。

结果
Loading...
实时编辑器
function MoreActionsDropdownDemo() {
  const buildMoreAction = (label) => ({ setActionsOpen }) => (
    <DropdownMenu
      onOpenChange={setActionsOpen}
      items={[
        { label: '重命名', onSelect: () => alert(`重命名 ${label}`) },
        { label: '复制', onSelect: () => alert(`复制 ${label}`) },
        { type: 'separator' },
        { label: '删除', variant: 'destructive', onSelect: () => alert(`删除 ${label}`) },
      ]}
    >
      <button
        type="button"
        className="size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none"
        aria-label={`${label} 的更多操作`}
      >

      </button>
    </DropdownMenu>
  )

  return (
    <Tree
      defaultExpandedKeys={['workspace']}
      treeData={[
        {
          key: 'workspace',
          title: '工作区',
          actions: buildMoreAction('工作区'),
          children: [
            { key: 'workspace/inbox', title: '收件箱', actions: buildMoreAction('收件箱') },
            { key: 'workspace/drafts', title: '草稿', actions: buildMoreAction('草稿') },
          ],
        },
      ]}
    />
  )
}

点击打开 Modal

把触发按钮包在 Dialog(即 Modal)中,按钮只负责打开 modal,行 onSelect / onExpand 不受影响。把 DialogonOpenChange 接到 setActionsOpen,modal 打开期间行保持 active 视觉。

结果
Loading...
实时编辑器
function MoreActionsModalDemo() {
  const [activeKey, setActiveKey] = React.useState(null)

  const buildSettingsTrigger = (node) => ({ setActionsOpen }) => (
    <Dialog
      title={`目录设置 — ${node.title}`}
      onOpenChange={setActionsOpen}
      content={
        <div className="flex flex-col gap-(--Spacing_8) text-(--Labels-Secondary)">
          <p>已打开「{node.title}」的设置面板。</p>
          <p>这里挂载真实表单字段;关闭后会回到树视图,选中状态不丢。</p>
        </div>
      }
      okText="保存"
      cancelText="取消"
      onOk={() => setActiveKey(node.key)}
    >
      <button
        type="button"
        className="size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none"
        aria-label={`${node.title} 设置`}
      >

      </button>
    </Dialog>
  )

  const nodes = [
    { key: 'projects', title: '项目' },
    { key: 'archive', title: '归档' },
  ]

  return (
    <div className="flex flex-col gap-(--Spacing_8)">
      <Tree
        treeData={nodes.map((node) => ({ ...node, actions: buildSettingsTrigger(node) }))}
      />
      {activeKey != null && (
        <div className="text-(length:--Font-Size-Caption) text-(--Labels-Tertiary)">
          最近保存:<code>{activeKey}</code>
        </div>
      )}
    </div>
  )
}

超长标题

Tree 的宽度完全由外层容器决定,组件自身没有内置 max-width。当 title 文本溢出时,右侧边缘通过 CSS mask-image 渐变淡出,提示还有更多内容。hover 时 actions 按钮绝对定位叠加在淡出区域上方,不影响 title 的布局宽度。

超长的 title 还会自动加 tooltip(悬停延迟 300ms)展示完整内容。超长判定采用「克隆离屏节点测自然宽度 vs 容器宽度」的方式(而非 scrollWidth),因此与渐变遮罩及任意 ReactNode title 都兼容;容器尺寸变化时经 ResizeObserver 重测。未超长的 title 不会引入 tooltip。

结果
Loading...
实时编辑器
function LongTitleDemo() {
  const buildAction = (label) => ({ setActionsOpen }) => (
    <DropdownMenu
      onOpenChange={setActionsOpen}
      items={[
        { label: '重命名', onSelect: () => alert(`重命名 ${label}`) },
        { label: '删除', variant: 'destructive', onSelect: () => alert(`删除 ${label}`) },
      ]}
    >
      <button
        type="button"
        className="size-5 inline-flex items-center justify-center rounded-(--Radius_5) text-(--Labels-Tertiary) hover:bg-(--Grays-Gray-2) outline-none"
        aria-label={`${label} 的更多操作`}
      >

      </button>
    </DropdownMenu>
  )

  return (
    <div style={{ width: 220 }}>
      <Tree
        defaultExpandedKeys={['folder']}
        treeData={[
          {
            key: 'long1',
            title: '这条录音有一个非常非常长的标题会发生溢出',
            actions: buildAction('long1'),
          },
          {
            key: 'folder',
            title: '项目文件夹名字也很长也会溢出',
            actions: buildAction('folder'),
            children: [
              {
                key: 'child1',
                title: '子节点的标题同样很长也会发生溢出',
                actions: buildAction('child1'),
              },
              { key: 'child2', title: '短标题' },
            ],
          },
        ]}
      />
    </div>
  )
}

鼠标悬浮任意项可看到 actions 按钮出现在渐变区域上方,悬停超长标题 300ms 可看到完整内容 tooltip。调整容器 width 可测试不同宽度下的表现。

受控展开

通过 expandedKeys + onExpand 完全受控展开;selectedKeys + onSelect 完全受控选中。

结果
Loading...
实时编辑器
function ControlledTree() {
  const [expandedKeys, setExpandedKeys] = React.useState(['root'])
  const [selectedKeys, setSelectedKeys] = React.useState([])

  return (
    <Tree
      treeData={[
        {
          key: 'root',
          title: '工作区',
          children: [
            { key: 'docs', title: '文档' },
            { key: 'audio', title: '音频' },
          ],
        },
      ]}
      expandedKeys={expandedKeys}
      selectedKeys={selectedKeys}
      onExpand={(keys) => setExpandedKeys(keys)}
      onSelect={(keys) => setSelectedKeys(keys)}
    />
  )
}

拖拽

使用原生 HTML5 DnD,无需额外依赖。传入 draggableonDrop;组件不会改写 treeData,请在回调里用 moveTreeNode(自 @plaud/design 导出)或自行拼装新数组。每行纵向按 25% / 50% / 25% 划分 before / inside / after;叶子行将中部合并为下半区。禁止将祖先拖入其后代。**禁用行不可作为 drop 投放目标。**折叠 folder 在连续 dragOver 满 500ms 时会触发 onExpand(受控模式下请自行更新 expandedKeys)。

拖拽视觉遵循 Figma 规范:拖拽源行显示 1px Separators/Emphasized 内描边;行间投放在 4px 行间隙内渲染 Labels/Link 圆点 + 横线指示器(inside 投放时整行高亮);浏览器默认拖拽影像会被替换为由行内 icon + title 构建的白底阴影预览浮层(经 setDragImage 注入)。

结果
Loading...
实时编辑器
function DragTree() {
  const [treeData, setTreeData] = React.useState([
    { key: 'a', title: 'Alpha' },
    {
      key: 'b',
      title: 'Beta',
      children: [
        { key: 'b1', title: 'Beta-1' },
        { key: 'b2', title: 'Beta-2' },
      ],
    },
  ])
  const [expandedKeys, setExpandedKeys] = React.useState(['b'])

  return (
    <Tree
      treeData={treeData}
      expandedKeys={expandedKeys}
      onExpand={(keys) => setExpandedKeys(keys)}
      draggable
      onDrop={(info) => {
        setTreeData((prev) =>
          moveTreeNode(prev, info.dragNode.key, info.dropNode.key, info.dropPosition),
        )
      }}
    />
  )
}

外部拖入

传入 onExternalDrop 即可接受从树外部拖入的元素(例如右侧文件列表)。回调提供 dropNodedropPosition 以及原始 DragEvent,调用方从 event.dataTransfer 中读取自定义拖拽数据。allowExternalDrop 可限制哪些 drop 区域有效。折叠 folder 悬停 500ms 同样会自动展开。

注意PhotosVideos叶子节点(无 children)的 drop position 被强制为 -1 / 1,永远不会是 0。若需要限制"只投放到文件夹内部",allowExternalDrop 应同时兼容叶子节点,例如:

allowExternalDrop={({ dropNode, dropPosition }) =>
dropPosition === 0 || !dropNode.children?.length
}
结果
Loading...
实时编辑器
function ExternalDropDemo() {
  const [expandedKeys, setExpandedKeys] = React.useState([])
  const [log, setLog] = React.useState(null)

  const folders = [
    {
      key: 'documents',
      title: '文档',
      children: [
        { key: 'work', title: '工作' },
        { key: 'personal', title: '个人' },
      ],
    },
    {
      key: 'media',
      title: '媒体',
      children: [
        { key: 'photos', title: '图片' },
        { key: 'videos', title: '视频' },
      ],
    },
    {
      key: 'archive',
      title: '归档',
      children: [{ key: 'archive-2023', title: '2023' }],
    },
  ]

  const files = [
    { key: 'report.pdf', label: '📄 report.pdf' },
    { key: 'photo.jpg', label: '🖼 photo.jpg' },
    { key: 'notes.txt', label: '📝 notes.txt' },
    { key: 'video.mp4', label: '🎬 video.mp4' },
  ]

  return (
    <div style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
      {/* 左侧:文件夹树 */}
      <div style={{ width: 200, border: '1px solid #e5e5e5', borderRadius: 8, padding: '8px 0' }}>
        <div style={{ padding: '0 12px 6px', fontSize: 12, color: '#999' }}>文件夹</div>
        <Tree
          treeData={folders}
          expandedKeys={expandedKeys}
          onExpand={(keys) => setExpandedKeys(keys)}
          onExternalDrop={({ dropNode, dropPosition, event }) => {
            const fileKey = event.dataTransfer.getData('application/x-file-key')
            const posLabel = dropPosition === 0 ? '放入' : dropPosition === -1 ? '插入前' : '插入后'
            setLog(`"${fileKey}" → ${dropNode.title}${posLabel}`)
          }}
        />
      </div>

      {/* 右侧:文件列表 */}
      <div style={{ flex: 1 }}>
        <div style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>
          文件 — 拖入左侧文件夹(悬停折叠文件夹 1s 可展开)
        </div>
        <div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
          {files.map((file) => (
            <div
              key={file.key}
              draggable
              onDragStart={(e) => {
                e.dataTransfer.setData('application/x-file-key', file.key)
                e.dataTransfer.effectAllowed = 'copy'
              }}
              style={{
                padding: '6px 12px',
                border: '1px solid #e5e5e5',
                borderRadius: 6,
                cursor: 'grab',
                fontSize: 14,
                background: '#fafafa',
                userSelect: 'none',
              }}
            >
              {file.label}
            </div>
          ))}
        </div>
        {log && (
          <div
            style={{
              marginTop: 12,
              fontSize: 13,
              padding: '6px 10px',
              background: '#f0fdf4',
              border: '1px solid #bbf7d0',
              borderRadius: 6,
              color: '#166534',
            }}
          >
{log}
          </div>
        )}
      </div>
    </div>
  )
}

尺寸 & Token

元素取值
行高min-height 32px(Spacing_32)
行内边距左 8px / 右 4px
行内 gap(槽位 / 标题)8px(Spacing_8)
层级缩进首层子级 12px,此后每层 +16px(indent 默认 16)
左侧槽位(chevron / icon 共用)20×20
字体Body 14 / 20(Font-Size-Body / Line-Height-Body)
Drop 指示器∅8 圆点(2px 描边)+ 2px 横线,渲染于 4px 行间隙
元素Token
标题颜色(Default)Labels/Primary#000000
Switcher / Icon 颜色Labels/Tertiary#757575
Hover 背景Grays/Gray-1#EBEBEB
Selected 背景(默认)Grays/Gray-1#EBEBEB
拖拽源描边Separators/Emphasized#CCCCCC
Drop 指示器(圆点 + 横线)Labels/Link#1573D1
拖拽预览背景Grays/White#FFFFFF

Props

Prop类型默认值说明
treeDataTreeDataNode[]-树数据(必填)
expandedKeysstring[]-受控展开 keys
defaultExpandedKeysstring[][]默认展开 keys
defaultExpandAllbooleanfalse是否默认展开所有可展开节点
selectedKeysstring[]-受控选中 keys
defaultSelectedKeysstring[][]默认选中 keys
onExpand(keys, { node, expanded }) => void-展开 / 折叠回调
onSelect(keys, { node, selected }) => void-选中回调
draggableboolean | ((node: TreeDataNode) => boolean)-启用拖拽源;函数形式可按节点过滤
allowDrop(info: TreeAllowDropInfo) => boolean-返回 false 拒绝 drop(成环由组件兜底)
onDragStart(info: TreeDragNodeInfo) => void-行上开始拖拽
onDragEnter(info: TreeDragEnterInfo) => void-拖拽进入某行
onDragLeave(info: TreeDragNodeInfo) => void-拖拽离开某行
onDragEnd(info: TreeDragNodeInfo) => void-拖拽结束(含 drop 之后)
onDrop(info: TreeDropInfo) => void-成功 drop;由调用方更新 treeData
allowExternalDrop(info: TreeExternalAllowDropInfo) => boolean-返回 false 拒绝外部 drop
onExternalDrop(info: TreeExternalDropInfo) => void-外部元素投放回调;从 info.event.dataTransfer 读取拖拽源数据
showIconbooleantrue是否渲染节点 icon
indentnumber16每层缩进像素;按 Figma 规范首层子级实际缩进 indent - 4
selectedColorstringvar(--Grays-Gray-1)选中行背景色,接受任意 CSS color / var() 表达式
classNamestring-根 className

TreeDropPosition 取值为 -1(插入到目标前)|0(作为目标首子)|1(插入到目标后)。TreeDropInfodropToGapisCrossParent。不可变工具:moveTreeNode(treeData, dragKey, dropKey, dropPosition)isDescendant(treeData, ancestorKey, candidateKey)resolveDropPosition({ clientY, rowTop, rowHeight, isLeafRow })(与组件内 drop 几何一致)由 @plaud/design 导出。

TreeDataNode

字段类型说明
keystring节点唯一标识(必填)
titleReactNode节点标题(必填)。计数 / 副标题等附属信息直接拼到 title 里渲染。
iconReactNode | ((state: { expanded; hover }) => ReactNode)节点前置图标(渲染在 20×20 槽位;folder 行 hover 时该槽位以 opacity 渐变切换为 chevron)。函数形态可按 expanded / hover 切换图标。
actionsReactNode | ((state: TreeNodeActionsState) => ReactNode)hover-only 操作位(如 more 菜单)。hover / focus-within 或行内浮层打开期间显示;内部点击不冒泡触发节点 select / expand。函数形态接收 setActionsOpen,把它接到浮层 onOpenChange 即可在浮层打开期间维持 active 视觉。
childrenTreeDataNode[]子节点
isLeafboolean强制声明叶子节点
disabledboolean禁用节点(不可选中 / 不可拖拽 / 不可作为 drop 投放目标 / 不响应指针;hover 也不切到 chevron 或 actions)
classNamestring节点 className