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 规范
基础用法
<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。
<Tree defaultSelectedKeys={['selected']} treeData={[ { key: 'default', title: '默认项 — 鼠标悬浮试试' }, { key: 'selected', title: '选中项' }, { key: 'disabled', title: '禁用项', disabled: true }, ]} />
自定义 selected 颜色
Tree 的 selectedColor 可覆盖选中行背景。接受任意 CSS color 或 var() 表达式,通常传 design token:
<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 切换"文件夹关 / 开"图标:
<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 视觉。
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 不受影响。把 Dialog 的 onOpenChange 接到 setActionsOpen,modal 打开期间行保持 active 视觉。
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。
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 完全受控选中。
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,无需额外依赖。传入 draggable 与 onDrop;组件不会改写 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 注入)。
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 即可接受从树外部拖入的元素(例如右侧文件列表)。回调提供 dropNode、dropPosition 以及原始 DragEvent,调用方从 event.dataTransfer 中读取自定义拖拽数据。allowExternalDrop 可限制哪些 drop 区域有效。折叠 folder 悬停 500ms 同样会自动展开。
注意:
Photos、Videos等叶子节点(无 children)的 drop position 被强制为-1 / 1,永远不会是0。若需要限制"只投放到文件夹内部",allowExternalDrop应同时兼容叶子节点,例如:allowExternalDrop={({ dropNode, dropPosition }) =>dropPosition === 0 || !dropNode.children?.length}
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 | 类型 | 默认值 | 说明 |
|---|---|---|---|
treeData | TreeDataNode[] | - | 树数据(必填) |
expandedKeys | string[] | - | 受控展开 keys |
defaultExpandedKeys | string[] | [] | 默认展开 keys |
defaultExpandAll | boolean | false | 是否默认展开所有可展开节点 |
selectedKeys | string[] | - | 受控选中 keys |
defaultSelectedKeys | string[] | [] | 默认选中 keys |
onExpand | (keys, { node, expanded }) => void | - | 展开 / 折叠回调 |
onSelect | (keys, { node, selected }) => void | - | 选中回调 |
draggable | boolean | ((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 读取拖拽源数据 |
showIcon | boolean | true | 是否渲染节点 icon |
indent | number | 16 | 每层缩进像素;按 Figma 规范首层子级实际缩进 indent - 4 |
selectedColor | string | var(--Grays-Gray-1) | 选中行背景色,接受任意 CSS color / var() 表达式 |
className | string | - | 根 className |
TreeDropPosition 取值为 -1(插入到目标前)|0(作为目标首子)|1(插入到目标后)。TreeDropInfo 含 dropToGap 与 isCrossParent。不可变工具:moveTreeNode(treeData, dragKey, dropKey, dropPosition)、isDescendant(treeData, ancestorKey, candidateKey) 与 resolveDropPosition({ clientY, rowTop, rowHeight, isLeafRow })(与组件内 drop 几何一致)由 @plaud/design 导出。
TreeDataNode
| 字段 | 类型 | 说明 |
|---|---|---|
key | string | 节点唯一标识(必填) |
title | ReactNode | 节点标题(必填)。计数 / 副标题等附属信息直接拼到 title 里渲染。 |
icon | ReactNode | ((state: { expanded; hover }) => ReactNode) | 节点前置图标(渲染在 20×20 槽位;folder 行 hover 时该槽位以 opacity 渐变切换为 chevron)。函数形态可按 expanded / hover 切换图标。 |
actions | ReactNode | ((state: TreeNodeActionsState) => ReactNode) | hover-only 操作位(如 more 菜单)。hover / focus-within 或行内浮层打开期间显示;内部点击不冒泡触发节点 select / expand。函数形态接收 setActionsOpen,把它接到浮层 onOpenChange 即可在浮层打开期间维持 active 视觉。 |
children | TreeDataNode[] | 子节点 |
isLeaf | boolean | 强制声明叶子节点 |
disabled | boolean | 禁用节点(不可选中 / 不可拖拽 / 不可作为 drop 投放目标 / 不响应指针;hover 也不切到 chevron 或 actions) |
className | string | 节点 className |