Demo 总览
24 个可交互用例,逐条对应源项目 playground 的演示场景;源码由构建期读取真实组件文件生成。
这 24 个用例与源项目 playground/components/demos/ 一一对应(编号见开发计划的阶段 8 契约表),
用来做「特性是否对齐」的人工核对。每个用例下面折叠的源码就是这一块正在跑的组件文件本身:
<DemoBlock> 在构建期用 readFileSync 读它,再用 shiki 高亮成 HTML,所以文档里的代码与页面里
跑着的代码不会各写一份再漂移,浏览器也不为此背一个 shiki。
数据全部来自 components/demo/data.ts 的工厂函数——每次调用返回新副本。原因在
数据变更与源数据回写:append / remove 这类方法会写回你传进来的源数据,
多个用例共用一份会互相污染。
布局与展开
基础用法(垂直)
只传 data,默认 direction="vertical"。
查看源码
'use client'
import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 基础用法(对应源项目 playground/components/demos/Base01.vue)
*
* 每个 demo 文件都是「只放活组件」的客户端组件:标题、说明与源码由 MDX 页面交给
* `<DemoBlock>`(源码由它按 `file` 读本文件,不在这份代码里再抄一遍)。
*
* `useMemo` 是必需的而不是习惯问题:data 换引用会触发 store 全量重建(requirements R2),
* 用户刚点开的展开态会被冲掉。
*/
export function BasicDemo() {
const data = useMemo(baseData, [])
return <OkrTree data={data} />
}
水平方向
direction="horizontal":同层节点竖排成一列,整体向右生长,这是组织架构图常用的形态。
查看源码
'use client'
import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 水平方向(对应源项目 playground/components/demos/Base02.vue)
*
* `direction="horizontal"` 下同层节点竖排成一列、整棵树向右生长,是组织架构图的常用形态;
* 不传则为 `vertical`(向下生长)。
*
* 两个与方向相关的约束:
* - OKR 模式的 `onlyBothTree`(子树在根节点左右两侧展开)只在 horizontal 下有效,
* 垂直方向配它会在开发期给一次警告。
* - `direction` 是创建期快照的 prop,运行时换值不会重排已有的树(React 侧要换方向
* 请给组件绑 `key` 重挂载);Vue 版同样是创建期生效,只是宿主改 `:data` 时会顺带重建。
*/
export function HorizontalDemo() {
const data = useMemo(baseData, [])
return <OkrTree data={data} direction="horizontal" />
}
是否可展开
showCollapsable 打开圆盘才有收起动作;不打开时组件强制全部展开(原版既有行为)。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 节点是否可展开(对应源项目 playground/components/demos/Base03.vue)
*
* 三个开关对比着看:
* - `showCollapsable` 关掉时根本不给收起的动作,组件**强制全部展开**(原版既有行为),
* 所以它是另外两个前提:圆盘不出现,`defaultExpandAll` 与 `showNodeNum` 都无从谈起。
* - `defaultExpandAll` 只是「默认」而不是「受控」:一进来全展开,用户照样能逐个收起。
* - `showNodeNum` 在折叠的圆盘里显示子节点数(只算通过过滤的可见子节点)。
*
* 圆盘打开后键盘操作与源项目一致:Tab 进入树,→ 展开 / 进入子节点,← 收起 / 回到父节点。
*
* React 与 Vue 的一处差别:`showCollapsable` 与 `showNodeNum` 运行时换值会立刻重绘(组件对
* 这两类 prop 做了全树通知),但 `defaultExpandAll` 是**初始态**语义——它只在节点创建时决定
* 展开状态,运行时换值不会把已经手动收起的节点再摊开。所以本例只把它拼进 `key` 触发重挂载,
* 让「默认全展开」这一档可以反复重放。Vue 版同样不会重放初始值,只是那边一般直接重挂组件。
*/
export function CollapsableDemo() {
const data = useMemo(baseData, [])
const [showCollapsable, setShowCollapsable] = useState(true)
const [defaultExpandAll, setDefaultExpandAll] = useState(false)
const [showNodeNum, setShowNodeNum] = useState(false)
const toggles = [
['showCollapsable', showCollapsable, setShowCollapsable],
['defaultExpandAll', defaultExpandAll, setDefaultExpandAll],
['showNodeNum', showNodeNum, setShowNodeNum],
] as const
return (
<div>
<div className="mb-3 flex flex-wrap gap-2 text-sm">
{toggles.map(([name, on, set]) => (
<button
key={name}
type="button"
aria-pressed={on}
onClick={() => set(!on)}
className={`rounded-lg border border-fd-border px-3 py-1 ${
on ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
}`}
>
{`${name}=${on}`}
</button>
))}
</div>
<OkrTree
key={`expand-all-${defaultExpandAll}`}
data={data}
direction="horizontal"
showCollapsable={showCollapsable}
defaultExpandAll={defaultExpandAll}
showNodeNum={showNodeNum}
/>
</div>
)
}
默认全部展开
defaultExpandAll。需要一进来就全展开时用它,不要挂载后再调 expandAll()——后者要走一次整树重渲染。
查看源码
'use client'
import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 默认全部展开(对应源项目 playground/components/demos/Base04.vue)
*
* `defaultExpandAll` 需要和 `showCollapsable` 一起用:不开圆盘时组件本身就是全展开的
* (没有收起的入口),开了圆盘又不开这个 prop,进来的默认态是「只见根节点」。
*
* 它是「默认态」而不是「锁死全展开」:挂载后用户照样能逐个收起。
* 也正因为只在节点创建时生效,想要一进来就全展开请直接用这个 prop,
* 别等挂载后再调 `handle.expandAll()`——那会多走一次整树通知。
* 运行时改这个 prop 不会重放(React 侧换 `key` 重挂载才会重来,见「是否可展开」用例)。
*/
export function ExpandAllDemo() {
const data = useMemo(baseData, [])
return <OkrTree data={data} direction="horizontal" showCollapsable defaultExpandAll />
}
指定默认展开的节点
nodeKey + defaultExpandedKeys:命中节点的祖先链自动展开。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, type TreeKey } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
/**
* 指定默认展开的节点(对应源项目 playground/components/demos/Base041.vue)
*
* `defaultExpandedKeys` 命中节点的**整条祖先链**会一并展开,所以本例展开 `[5]`(UI 设计,
* 一个叶子)看到的是「根 → 产品研发部 → UI 设计」这条路径亮了。前提是给 `nodeKey`:
* 它是注册表的主键,缺了这些 key 找不到节点(开发期会警告并且完全不起作用)。
*
* 语义上有两点容易踩:
* 1. 换一批 key 只「叠加展开」,不会把上一批收起——它是默认值,不是受控值;
* 要能收要放得用 `expandedKeys` + `onExpandedKeysChange`(受控用法见对应用例)。
* 2. 数组必须是稳定引用。这里用模块级常量而不是在 JSX 里写 `[5]`:组件按引用变化
* 来重新应用这批 key,每次渲染新建字面量会把用户手动收起的节点又弹开(requirements R2)。
*/
const EXPANSIONS: { label: string; keys: TreeKey[] }[] = [
{ label: '[5] UI 设计', keys: [5] },
{ label: '[2] 产品研发部', keys: [2] },
{ label: '[7, 8] 销售部两支', keys: [7, 8] },
]
export function DefaultExpandedKeysDemo() {
const data = useMemo(keyedData, [])
const [expansion, setExpansion] = useState(EXPANSIONS[0])
return (
<div>
<div className="mb-3 flex flex-wrap gap-2 text-sm">
<span className="text-fd-muted-foreground">defaultExpandedKeys</span>
{EXPANSIONS.map(item => (
<button
key={item.label}
type="button"
aria-pressed={item === expansion}
onClick={() => setExpansion(item)}
className={`rounded-lg border border-fd-border px-3 py-1 ${
item === expansion
? 'bg-fd-accent text-fd-accent-foreground'
: 'text-fd-muted-foreground'
}`}
>
{item.label}
</button>
))}
</div>
<OkrTree
data={data}
direction="horizontal"
showCollapsable
nodeKey="id"
defaultExpandedKeys={expansion.keys}
/>
</div>
)
}
节点尺寸与类名
labelWidth / labelHeight(number 走 px,string 原样)、labelClassName 与 currentLableClassName(后者是原版拼写,刻意保留)。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 节点的样式(对应源项目 playground/components/demos/Base05.vue)
*
* - `labelWidth` / `labelHeight`:number 按 px,string 原样进 style,`undefined` 即 auto。
* 上面三个按钮同时改这两个值——它们属于渲染配置,而节点组件是 memo 的,
* 运行时换 prop 不会让已渲染的卡片重绘(Vue 的模板会自动跟上),所以按钮把值拼进
* `key` 重挂载。日常用法是定好尺寸后就不改了。
* - `labelClassName` / `currentLableClassName`(后者是原版拼写,刻意保留):类名加在卡片
* `.org-chart-node-label-inner` 上,接受固定字符串或 `Function(node)`;入参是内部 TreeNode,
* 源数据在 `node.data`。这两个 prop 组件内部会逐节点通知,所以可以运行时改。
* - **默认主题下选中态本身没有任何外观**(卡片外观用 `:where()` 声明成零优先级,
* 就是为了让你经 `currentLableClassName` 传的类直接盖上去),点一个节点才看得出差别。
*
* 类名这里用文档站现成的 Tailwind utility,带 `!` 是因为本站的 utilities 在 `@layer` 里、
* 而库样式是无层的(同属性时未层声明优先)。自己的项目里写普通 class 就不用这个 `!`。
*/
function labelClassName(node: TreeNode) {
if (node.level === 1) return 'font-semibold underline'
return node.isLeaf ? 'opacity-60' : undefined
}
function currentLableClassName() {
return 'rounded-md! bg-fd-primary! text-fd-primary-foreground!'
}
const SIZES: { label: string; width?: string | number; height?: string | number }[] = [
{ label: 'auto(默认)' },
{ label: '140 × 40', width: 140, height: 40 },
{ label: "'8rem' × auto", width: '8rem' },
]
export function NodeStyleDemo() {
const data = useMemo(baseData, [])
const [size, setSize] = useState(SIZES[0])
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2 text-sm">
<span className="text-fd-muted-foreground">labelWidth × labelHeight</span>
{SIZES.map(item => (
<button
key={item.label}
type="button"
aria-pressed={item === size}
onClick={() => setSize(item)}
className={`rounded-lg border border-fd-border px-3 py-1 ${
item === size ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
}`}
>
{item.label}
</button>
))}
</div>
<OkrTree
key={size.label}
data={data}
direction="horizontal"
showCollapsable
defaultExpandAll
labelWidth={size.width}
labelHeight={size.height}
labelClassName={labelClassName}
currentLableClassName={currentLableClassName}
/>
</div>
)
}
节点内容定制
优先级 renderNode > nodeComponent > renderContent > node.label。点卡片选中节点,三种写法里都读得到node.isCurrent。
三种内容写法对比
优先级 renderNode > nodeComponent > renderContent > 内置 node.label。React 版的 renderContent 只收 (node),没有 Vue 的 h。
查看源码
'use client'
import { useMemo, useState } from 'react'
import {
OkrTree,
type NodeComponent,
type NodeComponentProps,
type RenderContentFunction,
type TreeNode,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { contentData } from './data'
/**
* 节点内容定制的三种写法对比(对应源项目 playground/components/demos/Base06.vue)
*
* 三种写法渲染同一张卡片,卡片左上角标出「这一张是从哪个口渲染出来的」;
* 「三种同传」这一档用来验优先级:renderNode > nodeComponent > renderContent > node.label。
*
* 与 Vue 用例的四处差别:
* 1. `renderContent(node)` 只收 node,没有 Vue 传进来的 `h`(D1),直接返回 JSX;
* 2. `#default` 作用域插槽 → `renderNode` prop(`children` 传函数等价),
* `node-component` → `nodeComponent`,入参形状仍是 `{ node, data }`;
* 3. Vue 用 `markRaw(defineComponent(...))` 防 reactive 代理,React 没这个问题,
* 但组件身份同样要稳定:`DiyCard` 必须定义在模块作用域——写在渲染函数里,
* 每次渲染都是一个「新的组件类型」,卡片会整棵重挂载;
* 4. 切换写法**不需要**重挂载:渲染定制虽然走 `configRef`(换回调身份不该让整树重渲染,R1),
* 但组件对这一组 prop 做了一次全树通知,所以换了口已挂载的节点会立刻重绘,展开态保住。
* (这一条是文档 demo 反过来发现的库缺口:早期版本换 renderContent 视觉上毫无反应,
* 当时只能靠 `key` 重挂载绕过。)
*/
const ROUTES = ['label', 'renderContent', 'nodeComponent', 'renderNode', 'all'] as const
type Route = (typeof ROUTES)[number]
function Card({ node, via }: { node: TreeNode; via: string }) {
return (
<div
className={`flex flex-col items-start text-left ${
node.isCurrent ? 'text-fd-primary' : 'text-fd-foreground'
}`}
>
<span className="text-[10px] text-fd-muted-foreground">{via}</span>
<span className="text-sm font-medium">{node.label}</span>
<span className="text-xs text-fd-muted-foreground">{node.data.content}</span>
</div>
)
}
/** 写法一:内容区渲染函数,只有 node(源数据在 node.data,文本在 node.label) */
const renderContent: RenderContentFunction = node => <Card node={node} via="renderContent" />
/** 写法二:内容组件,props 为 { node, data } */
const DiyCard: NodeComponent = ({ node }) => <Card node={node} via="nodeComponent" />
/** 写法三:整节点渲染(对应源项目 #default 插槽),优先级最高 */
const renderNode = ({ node }: NodeComponentProps) => <Card node={node} via="renderNode" />
export function ContentModesDemo() {
const data = useMemo(contentData, [])
const [route, setRoute] = useState<Route>('renderContent')
const on = (key: Route) => route === key || route === 'all'
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">写法:</span>
{ROUTES.map(key => (
<button
key={key}
type="button"
onClick={() => setRoute(key)}
className={`rounded-lg border border-fd-border px-2 py-1 text-sm ${
route === key ? 'bg-fd-accent font-medium' : 'text-fd-muted-foreground'
}`}
>
{key === 'all' ? '三种同传' : key}
</button>
))}
</div>
<p className="text-sm text-fd-muted-foreground">
优先级 <code>renderNode</code> > <code>nodeComponent</code> >{' '}
<code>renderContent</code> > <code>node.label</code>。点卡片选中节点,三种写法里都读得到
<code>node.isCurrent</code>。
</p>
<OkrTree
data={data}
direction="horizontal"
showCollapsable
defaultExpandAll
renderContent={on('renderContent') ? renderContent : undefined}
nodeComponent={on('nodeComponent') ? DiyCard : undefined}
renderNode={on('renderNode') ? renderNode : undefined}
/>
</div>
)
}
点圆盘收起 / 展开即可看到自定义内容替掉了内置的 +/−;renderExpandBtn 拿到的 expanded 就是该侧当前状态。
展开按钮内容
renderExpandBtn(旧名 nodeBtnContent 仍在);showNodeNum 折叠时优先显示数字。
查看源码
'use client'
import { useMemo, useState } from 'react'
import {
OkrTree,
type ExpandBtnScope,
type NodeBtnContentFunction,
type TreeNode,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 展开圆盘内容定制(对应源项目 playground/components/demos/Base062.vue)
*
* 两个口 + 一条优先级:`showNodeNum` 的折叠数字 > `renderExpandBtn`(对应 #expand-btn 插槽)
* > `nodeBtnContent`(旧名,仍在 API 表里)> 内置 CSS 画的 +/−。「两个同传」与
* 「showNodeNum」两档就是给这条优先级用的。
*
* 与 Vue 用例的差别:
* 1. 插槽 → render prop:`renderExpandBtn(scope)` 的作用域形状与 `#expand-btn` 一致
* (`{ node, data, expanded, side, loading }`),`side` 在 OKR 左子树才会是 'left';
* 2. `nodeBtnContent(node)` 去掉了 Vue 的第一个参数 `h`(D1),只给 node;
* 3. 自定义内容要包一层内置类 `org-chart-node-btn-text`:它用不透明底铺满整个圆盘,
* 把 CSS 伪元素画的 +/− 盖掉——这属于 DOM 契约,不是可选美化;
* 4. 圆盘是 20px 的绝对定位元素,按钮里放长文案会溢出,本例只放单字符;
* 5. 换按钮口不需要重挂载:渲染定制这一组 prop 变更会被组件广播成一次全树重绘,
* 已挂载节点立刻跟上,用户的展开态不受影响。
*/
const MODES = ['default', 'nodeBtnContent', 'renderExpandBtn', 'both', 'showNodeNum'] as const
type Mode = (typeof MODES)[number]
/** 旧名:与 renderContent 同一套约定,只有 node */
const nodeBtnContent: NodeBtnContentFunction = (node: TreeNode) => (
<span className="org-chart-node-btn-text" title={node.label}>
智
</span>
)
/** 新名(等价 #expand-btn 插槽):拿得到该侧的展开态与懒加载态 */
const renderExpandBtn = ({ expanded, side, loading }: ExpandBtnScope) => (
<span className="org-chart-node-btn-text" title={side}>
{loading ? '…' : expanded ? '−' : '+'}
</span>
)
export function ExpandBtnDemo() {
const data = useMemo(baseData, [])
const [mode, setMode] = useState<Mode>('renderExpandBtn')
const useNum = mode === 'showNodeNum'
const useBtnContent = mode === 'nodeBtnContent' || mode === 'both' || useNum
const useScope = mode === 'renderExpandBtn' || mode === 'both' || useNum
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">按钮内容:</span>
{MODES.map(key => (
<button
key={key}
type="button"
onClick={() => setMode(key)}
className={`rounded-lg border border-fd-border px-2 py-1 text-sm ${
mode === key ? 'bg-fd-accent font-medium' : 'text-fd-muted-foreground'
}`}
>
{key === 'both' ? '两个同传' : key}
</button>
))}
</div>
<p className="text-sm text-fd-muted-foreground">
{useNum
? '树是展开的,随便收起一层就能看到圆盘里的数字:showNodeNum 优先,两个自定义口完全不会被调用。'
: '点圆盘收起 / 展开即可看到自定义内容替掉了内置的 +/−;renderExpandBtn 拿到的 expanded 就是该侧当前状态。'}
</p>
<OkrTree
data={data}
direction="horizontal"
showCollapsable
defaultExpandAll
showNodeNum={useNum}
nodeBtnContent={useBtnContent ? nodeBtnContent : undefined}
renderExpandBtn={useScope ? renderExpandBtn : undefined}
/>
</div>
)
}
展开动画
animate + animateName 六个方向 + animateDuration;系统开启「减弱动态效果」时自动按关闭处理。
查看源码
'use client'
import { useMemo, useRef, useState } from 'react'
import { OkrTree, type AnimateName, type OkrTreeHandle } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { baseData } from './data'
/**
* 展开 / 收起过渡动画(对应源项目 playground/components/demos/Base061.vue)
*
* `animate` 是开关,`animateName` 选六个内置名字之一,`animateDuration` 给时长(ms)。
* 「全部收起 / 全部展开」两个按钮走 ref,点一次能看到整树同时在动。
*
* 与 Vue 用例的差别:
* 1. 名字与取值集合完全一致(`AnimateName`),类名形态是 `is-animated` + `okr-anim-{name}`,
* 写在子节点容器上,不是包一层 `<Transition>` 组件;
* 2. `animateDuration` 在 React 侧不只是 CSS 变量:收起要等这么久才把容器压成 `height: 0`
* (`useDelayedCollapse`,R7),因为 `height: auto → 0` 不可插值,早压会让下方节点跳位。
* 所以把时长调到 1500ms 时,收起后的「留白」也一起变长;
* 3. 系统开「减弱动态效果」时组件按 animate=false 处理(JS 与 CSS 两侧都判),
* 所以这台机器上可能看不到动效,不是 bug;
* 4. 这三个 prop 运行时切换都真的生效(内部会逐节点通知),不需要像别的定制口那样重挂载。
*/
const NAMES: AnimateName[] = [
'okr-zoom-in-center',
'okr-zoom-in-top',
'okr-zoom-in-bottom',
'okr-zoom-in-left',
'okr-fade-in',
'okr-fade-in-linear',
]
export function AnimationDemo() {
const data = useMemo(baseData, [])
const tree = useRef<OkrTreeHandle>(null)
const [animate, setAnimate] = useState(true)
const [animateName, setAnimateName] = useState<AnimateName>('okr-zoom-in-center')
const [animateDuration, setAnimateDuration] = useState(200)
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">animate-name:</span>
{NAMES.map(name => (
<button
key={name}
type="button"
onClick={() => setAnimateName(name)}
className={`rounded-lg border border-fd-border px-2 py-1 text-sm ${
animateName === name ? 'bg-fd-accent font-medium' : 'text-fd-muted-foreground'
}`}
>
{name}
</button>
))}
</div>
<div className="flex flex-wrap items-center gap-4 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={animate}
onChange={event => setAnimate(event.target.checked)}
/>
animate
</label>
<label className="flex items-center gap-2">
<span className="text-fd-muted-foreground">animate-duration</span>
<input
className="w-24 rounded-lg border border-fd-border px-2 py-1"
type="number"
min={0}
step={100}
value={animateDuration}
onChange={event => setAnimateDuration(Number(event.target.value))}
/>
ms
</label>
<button
type="button"
onClick={() => tree.current?.collapseAll()}
className="rounded-lg border border-fd-border px-2 py-1"
>
全部收起
</button>
<button
type="button"
onClick={() => tree.current?.expandAll()}
className="rounded-lg border border-fd-border px-2 py-1"
>
全部展开
</button>
</div>
<OkrTree
ref={tree}
data={data}
direction="horizontal"
showCollapsable
defaultExpandAll
animate={animate}
animateName={animateName}
animateDuration={animateDuration}
/>
</div>
)
}
OKR 模式
OKR 双树与根对齐
两棵 onlyBothTree 的树并排,外面套 OkrTreeGroup 让左右子树宽度取组内最宽者;alignRoot 保证根节点水平坐标不随展开跳位。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, OkrTreeGroup } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData, leftData2 } from './data'
/**
* OKR 双树与跨实例根对齐(对应源项目 playground/components/demos/Base07.vue)
*
* 两棵 `onlyBothTree` 的树上下排开做对比,第二棵的左子树多一层(`leftData2`)。
* `alignRoot`(React 里默认 true)只负责「每棵树自己的根节点在自身容器内水平居中」:
* 纯 CSS,展开收起都不会让根节点跳位。但两棵树的左子树深度不同时,深的那一行会被
* `min-width: max-content` 撑得更宽,居中坐标就跟着偏——这正是 `<OkrTreeGroup>` 要解决的:
* 它量出组内所有左子树容器的最大自然宽度并统一写入,两棵树的根节点才落在同一条竖线上。
* 关掉 `align` 就能看到差别(等价于不套 Group,组会清掉已写入的宽度)。
*
* 与源用例的差别:Vue 侧把 group 开关和 `align-root` 绑在同一个变量上,这里拆成两个按钮,
* 免得读者把「各自居中」和「组内统一宽度」当成一件事。成员树传 `alignRoot={false}` 时,
* 组内对齐没有意义(Group 建在 alignRoot 的居中机制之上)。
*/
export function OkrGroupDemo() {
const [groupAlign, setGroupAlign] = useState(true)
const [alignRoot, setAlignRoot] = useState(true)
// 三份数据都必须稳定:换引用 = store 全量重建,展开态会被冲掉(requirements R2)
const [data, left, deeperLeft] = useMemo(() => [keyedData(), leftData(), leftData2()], [])
return (
<div>
<div className="mb-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setGroupAlign(value => !value)}
className={`rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors ${
groupAlign ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
}`}
>
OkrTreeGroup align:{groupAlign ? '开启' : '关闭'}
</button>
<button
type="button"
onClick={() => setAlignRoot(value => !value)}
className={`rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors ${
alignRoot ? 'bg-fd-accent text-fd-accent-foreground' : 'text-fd-muted-foreground'
}`}
>
alignRoot:{alignRoot ? '开启' : '关闭'}
</button>
</div>
<div className="overflow-x-auto">
<OkrTreeGroup align={groupAlign}>
<OkrTree
data={data}
leftData={left}
onlyBothTree
direction="horizontal"
nodeKey="id"
showCollapsable
defaultExpandAll
alignRoot={alignRoot}
/>
<OkrTree
data={data}
leftData={deeperLeft}
onlyBothTree
direction="horizontal"
nodeKey="id"
showCollapsable
defaultExpandAll
alignRoot={alignRoot}
/>
</OkrTreeGroup>
</div>
</div>
)
}
历史进展 · (左)研发-前端
这是一个有活力的研发-前端
历史进展 · (左)研发-后端
这是一个有活力的研发-后端
历史进展 · (左)UI 设计
这是一个有活力的UI 设计
历史进展 · (左)产品研发部
这是一个有活力的产品研发部
历史进展 · (左)销售一部
这是一个有活力的销售一部
历史进展 · (左)销售二部
这是一个有活力的销售二部
历史进展 · (左)销售部
这是一个有活力的销售部
历史进展 · (左)财务部
这是一个有活力的财务部
xxx科技有有限公司
这是一个有活力的公司
产品研发部
这是一个有活力的产品研发部
研发-前端
这是一个有活力的研发-前端
研发-后端
这是一个有活力的研发-后端
UI 设计
这是一个有活力的UI 设计
销售部
这是一个有活力的销售部
销售一部
这是一个有活力的销售一部
销售二部
这是一个有活力的销售二部
财务部
这是一个有活力的财务部
OKR 下的自定义内容
同一份 renderContent 里用 node.isLeftChild 分侧渲染——左树是同一组件的完整镜像,不是另一套 API。
查看源码
'use client'
import { useMemo } from 'react'
import { OkrTree, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { okrContentData, okrContentLeftData } from './data'
/**
* OKR 模式下的自定义节点内容(对应源项目 playground/components/demos/Base08.vue)
*
* 左右两棵树是**同一个组件、同一份 `renderContent`**,靠 `node.isLeftChild` 分侧:
* 右树渲染「标题 + 描述」卡片,左树渲染成信息层级反过来的一张卡,一眼看出两侧不是两套 API。
*
* 与源用例的差别:
* 1. Vue 的 `render-content` 签名是 `(h, node)`,React 侧去掉了框架注入的 `h`(requirements D1),
* 直接返回 JSX。
* 2. 源用例用 `label-class-name="no-padding"` 加一张全局样式表抵消卡片内边距;文档站的 demo
* 是单文件、没有自己的样式表,所以这里保留库默认内边距,内容不再自带 padding。
* 要自定义外观取值,用 `--okr-*` 变量或 `labelClassName`(库的外观规则是 `:where()`,
* 单个工具类就能覆盖)。
* 3. 自定义字段一律从 `node.data` 取(`node.label` 是按 `props.label` 解析后的文本)。
*/
export function OkrContentDemo() {
const [data, leftData] = useMemo(() => [okrContentData(), okrContentLeftData()], [])
function renderContent(node: TreeNode) {
const { content } = node.data
if (node.isLeftChild) {
return (
<div className="min-w-40 text-left">
<p className="text-xs text-fd-muted-foreground">历史进展 · {node.label}</p>
<p className="mt-1 text-sm">{content}</p>
</div>
)
}
return (
<div className="min-w-40 text-left">
<p className="text-sm font-medium">{node.label}</p>
<p className="mt-1 text-xs text-fd-muted-foreground">{content}</p>
</div>
)
}
return (
<div className="overflow-x-auto">
<OkrTree
data={data}
leftData={leftData}
onlyBothTree
direction="horizontal"
nodeKey="id"
showCollapsable
defaultExpandAll
renderContent={renderContent}
/>
</div>
)
}
OKR 折叠计数
showNodeNum 在左右两侧圆盘上分别显示可见子节点数(被 filter 隐藏的不计)。
查看源码
'use client'
import { useMemo } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData } from './data'
/**
* OKR 模式显示节点数(对应源项目 playground/components/demos/Base081.vue)
*
* `showCollapsable` 打开圆盘、`showNodeNum` 让圆盘里显示子节点数。OKR 根节点左右各一个圆盘,
* 所以同一份配置会在两侧分别报出自己那侧的可见子节点数;点圆盘收起/展开即可看到数字变化,
* 深层节点展开后再收起也会有数字。
*
* 与源用例的差别:源用例同时给了 `show-node-num` 和 `node-btn-content`(自己 return
* `node.childNodes.length`),React 侧不需要那半边——`showNodeNum` 优先于
* `nodeBtnContent` / `renderExpandBtn`,而且数字按「未被 filter 隐藏的可见子节点」计,
* 手写 `childNodes.length` 反而会在过滤后对不上视觉。
*/
export function OkrNodeNumDemo() {
const [data, left] = useMemo(() => [keyedData(), leftData()], [])
return (
<div className="overflow-x-auto">
<OkrTree
data={data}
leftData={left}
onlyBothTree
direction="horizontal"
nodeKey="id"
showCollapsable
showNodeNum
/>
</div>
)
}
状态、数据与画布
[1]currentKey:null受控状态与 ref 方法
expandedKeys / currentKey 成对 props 即受控;ref 上的方法入参普遍接受 key / data 对象 / TreeNode 三种形态。原地改数据后要宿主重渲染或调 refreshData()。
查看源码
'use client'
import { useRef, useState } from 'react'
import {
OkrTree,
type FilterNodeMethod,
type OkrTreeHandle,
type TreeKey,
type TreeNodeData,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
/**
* 受控展开/选中 + 走 ref 的方法(对应源项目 playground/components/demos/Base09.vue)
*
* `v-model:expanded-keys` / `v-model:current-key` 在 React 里拆成「值 + 回调」一对(D3):
* 传了值就是受控,`undefined` 才是非受控。下面这些方法按钮只「请求」,最终显示什么由
* 宿主回写的 state 决定——所以本文件里没有任何一处绕过 state 直接改 store。
*
* 与 Vue 用例的差别:
* 1. 模板 ref → `useRef<OkrTreeHandle>(null)`,28 个方法全在 handle 上;
* 2. 事件回调不再有第三个参数 `nodeComponent`(D2),要在 DOM 上做事用 `getNodeEl()`;
* 3. **`data` 放在 `useState` 里而不是每次渲染新建字面量**:这里要演示换引用;顺带一条边界——
* 「换外壳数组、元素逐个还是同引用」会被判为未变(那是给宿主每帧新建字面量兜的,R2),
* 既不全量重建也不跑脏检查;
* 4. 原地 `push` 一个节点后组件不会自己发现(引用没变,Vue 的 deep watch 在 React 没有对应物,
* 即 D7)。被看见只有两条路:**宿主用同一引用重渲染**(渲染期结构脏检查 → 增量更新,
* 保留各节点状态),或显式调 **`handle.refreshData()`**(不依赖宿主是否重渲染)。
* 换成一批新的元素引用则是全量重建,重建后按受控值恢复展开与选中。
*/
const BTN = 'rounded-lg border border-fd-border px-2 py-1 text-sm'
/** 空值必须返回 true,否则「清空输入 = 恢复全显」不成立 */
const filterNodeMethod: FilterNodeMethod = (value, data) =>
!value || String(data.label).includes(String(value))
export function ControlledDemo() {
const [data, setData] = useState<TreeNodeData[]>(keyedData)
const [expandedKeys, setExpandedKeys] = useState<TreeKey[]>([1])
const [currentKey, setCurrentKey] = useState<TreeKey | null>(null)
const [note, setNote] = useState('')
const tree = useRef<OkrTreeHandle>(null)
const idSeed = useRef(100)
/** 原地改源数据:引用不变,组件收不到任何通知 */
function pushChild(): number {
const id = ++idSeed.current
const children = (data[0].children ??= []) as TreeNodeData[]
children.push({ id, label: `原地新增 ${id}` })
return id
}
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-fd-muted-foreground">expandedKeys:</span>
<code>{JSON.stringify(expandedKeys)}</code>
<span className="text-fd-muted-foreground">currentKey:</span>
<code>{currentKey ?? 'null'}</code>
{note ? <span className="text-fd-muted-foreground">— {note}</span> : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">展开:</span>
<button className={BTN} type="button" onClick={() => tree.current?.expandAll()}>
expandAll()
</button>
<button className={BTN} type="button" onClick={() => tree.current?.collapseAll()}>
collapseAll()
</button>
<button className={BTN} type="button" onClick={() => tree.current?.expandNode(5)}>
expandNode(5)
</button>
<button className={BTN} type="button" onClick={() => setExpandedKeys([1, 6])}>
expandedKeys = [1, 6]
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">选中:</span>
<button className={BTN} type="button" onClick={() => tree.current?.setCurrentKey(8)}>
setCurrentKey(8)
</button>
<button
className={BTN}
type="button"
onClick={() => setNote(`getCurrentKey() → ${tree.current?.getCurrentKey() ?? 'null'}`)}
>
getCurrentKey()
</button>
<button className={BTN} type="button" onClick={() => setCurrentKey(null)}>
currentKey = null
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">数据与查询:</span>
<button
className={BTN}
type="button"
onClick={() => tree.current?.append({ id: ++idSeed.current, label: 'append 进来的' }, 1)}
>
append 到 id=1
</button>
<button className={BTN} type="button" onClick={() => tree.current?.remove(3)}>
remove(3)
</button>
<button className={BTN} type="button" onClick={() => tree.current?.filter('研发')}>
filter('研发')
</button>
<button
className={BTN}
type="button"
onClick={() => {
tree.current?.filter('')
setNote('filter("") 恢复全显')
}}
>
filter('')
</button>
<button className={BTN} type="button" onClick={() => void tree.current?.scrollToNode(8)}>
scrollToNode(8)
</button>
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-fd-muted-foreground">原地变更(R2 / D7):</span>
<button
className={BTN}
type="button"
onClick={() => setNote(`已 push id=${pushChild()},这次重渲染让组件的脏检查接住了它`)}
>
push + 靠宿主重渲染
</button>
<button
className={BTN}
type="button"
onClick={() => {
pushChild()
tree.current?.refreshData()
}}
>
push + refreshData()
</button>
<button
className={BTN}
type="button"
onClick={() => setData(data.map(item => ({ ...item })))}
>
换新引用(整树重建)
</button>
</div>
<OkrTree
ref={tree}
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
expandedKeys={expandedKeys}
onExpandedKeysChange={setExpandedKeys}
currentKey={currentKey}
onCurrentKeyChange={setCurrentKey}
filterNodeMethod={filterNodeMethod}
/>
</div>
)
}
懒加载子节点
lazy + load(node, resolve, reject);失败分支与重试、isLeaf 判定。
查看源码
'use client'
import { useCallback, useMemo, useRef, useState } from 'react'
import {
OkrTree,
type OkrTreeHandle,
type TreeLoadFunction,
type TreeNodeData,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
/**
* 懒加载子节点(对应源项目 playground/components/demos/Base10.vue)
*
* `lazy` + `load(node, resolve, reject)`:初始只给顶层,首次展开时才取数(本例固定 300ms 假延迟)。
* `SERVER` 是假的服务端表,`leaf: true` 配合 `props.isLeaf` 把圆盘收掉;
* id=5 这一支首次必 reject,配一个「重试」按钮走 `expandNode()` 再请求一次。
*
* 与 Vue 用例的差别(这一条 React 专属,必须留意):
* 1. **`load` 是创建期快照**(store 建好就不再换),所以它是个稳定身份:闭包里的 state
* 会永远停在首次渲染那一刻。计数只能用函数式 `setRequests(n => n + 1)`、
* 跨次状态用 `useRef`,别直接读 state;
* 2. **`props`(字段映射)同样有代价**:它的身份一变,组件就把 `data` 重新塞给 store 走全量重建,
* 刚点开的展开态会被冲掉——所以 `TREE_PROPS` 必须在模块作用域定义,不能写内联字面量;
* 3. `resolve(children)` 会原地写进源数据的 `children`(Q3 回写),因此不需要换 `data` 引用、
* 也不需要 `refreshData()`(R2 那两条兜底路径在懒加载这里用不上);
* 4. 加载中的圆盘自带 `is-loading` 旋转;要在按钮上自己显示进度,用 `renderExpandBtn` 的
* `loading` 作用域(见 expand-btn 用例)。
*/
/** 首次必失败的那一支 */
const FAIL_ID = 5
/** 假的服务端:父 id → 子节点,缺项表示没有子节点 */
const SERVER: Record<number, TreeNodeData[]> = {
1: [
{ id: 2, label: '产品研发部' },
{ id: 6, label: '销售部' },
{ id: 9, label: '财务部(叶子)', leaf: true },
],
2: [
{ id: 3, label: '研发-前端', leaf: true },
{ id: 4, label: '研发-后端', leaf: true },
{ id: FAIL_ID, label: 'UI 设计(首次请求必失败)' },
],
[FAIL_ID]: [
{ id: 51, label: '视觉组' },
{ id: 52, label: '交互组' },
],
6: [
{ id: 7, label: '销售一部' },
{ id: 8, label: '销售二部' },
],
}
/** 初始只有顶层;带 leaf 的那条永远不会触发 load */
const lazyData = (): TreeNodeData[] => [
{ id: 1, label: 'xxx科技有有限公司' },
{ id: 90, label: '外部顾问(isLeaf,不请求)', leaf: true },
]
/** 字段映射必须引用稳定,见上面第 2 条 */
const TREE_PROPS = { isLeaf: 'leaf' }
export function LazyDemo() {
const data = useMemo(lazyData, [])
const tree = useRef<OkrTreeHandle>(null)
const [requests, setRequests] = useState(0)
const [failedId, setFailedId] = useState<number | null>(null)
const failedOnce = useRef(new Set<number>())
const load = useCallback<TreeLoadFunction>((node, resolve, reject) => {
setRequests(count => count + 1)
const id = node.data.id as number
window.setTimeout(() => {
if (id === FAIL_ID && !failedOnce.current.has(id)) {
failedOnce.current.add(id)
// reject:节点回到折叠态、清掉加载中标记,下次展开重新请求
setFailedId(id)
reject?.()
return
}
setFailedId(null)
resolve(SERVER[id] ?? [])
}, 300)
}, [])
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="text-fd-muted-foreground">
已发起 <strong>{requests}</strong> 次请求(每个节点至多一次,失败后重试会再多一次)
</span>
{failedId === null ? null : (
<button
type="button"
className="rounded-lg border border-fd-border bg-fd-accent px-2 py-1 text-sm"
onClick={() => tree.current?.expandNode(failedId)}
>
重试 id={failedId}
</button>
)}
</div>
{failedId === null ? null : (
<p className="text-sm text-fd-muted-foreground">
id={failedId} 首次 reject:节点保持折叠、圆盘不再有加载指示,点上面的重试即可。
</p>
)}
<OkrTree
ref={tree}
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
lazy
load={load}
props={TREE_PROPS}
/>
</div>
)
}
当前缩放 100% · 拖动画布平移;按住 Ctrl / Cmd 滚动缩放。
画布缩放与平移
OkrTreeViewport 包住树:滚轮/捏合/拖拽、fitToScreen、centerNode、exportImage(可选依赖 html-to-image 以注入函数的方式使用)。
查看源码
'use client'
import { useMemo, useRef, useState } from 'react'
import {
OkrTree,
OkrTreeViewport,
type OkrTreeViewportHandle,
type TreeNodeData,
type ViewportWheelBehavior,
} from 'react-okr-tree'
import 'react-okr-tree/style.css'
/**
* 画布组件 OkrTreeViewport(对应源项目 playground/components/demos/Base11.vue)
*
* 它只做外层 `transform: translate() scale()`,不侵入树本体:滚轮以指针为锚缩放、拖拽平移
* (3px 阈值,平移结束时吞掉随后那次 click)、双击复位、双指捏合。这里用 `toolbar` 的默认
* 工具栏,另外排一排按钮走 ref 方法:`fitToScreen()` / `centerNode(key)` / `exportImage()`。
* `wheelBehavior` 三个值都点一遍:默认 `ctrl-zoom` 不劫持页面滚动,`zoom` 直接缩放,
* `scroll` 完全不拦滚轮。`zoom` / `onZoomChange` 是受控写法(等价源项目 `v-model:zoom`),
* 范围由 `minZoom` / `maxZoom` 钳制。
*
* 与源用例的差别:`html-to-image` 在 React 版里是**可选 peer**,站里没装,所以导出走
* `exportImage({ toPng })` 的注入形式(下面那份 `toPng` 是本文件自带的最小实现)而不是
* 库内部的动态 import。装过 `html-to-image` 的项目把它的 `toPng` 传进来就行,不传则库按需
* 动态导入、没装时抛带安装指引的错。
*/
const WHEEL_MODES: Array<{ value: ViewportWheelBehavior; label: string }> = [
{ value: 'ctrl-zoom', label: 'Ctrl / Cmd + 滚轮缩放' },
{ value: 'zoom', label: '滚轮直接缩放' },
{ value: 'scroll', label: '滚轮滚动页面' },
]
/** 三层部门树:够宽才看得出缩放与平移的价值 */
function dept(id: number, label: string, depth: number): TreeNodeData {
if (depth <= 0) return { id, label }
return {
id,
label,
children: [
dept(id * 10 + 1, `${label}-A`, depth - 1),
dept(id * 10 + 2, `${label}-B`, depth - 1),
],
}
}
/**
* 注入给 `exportImage` 的渲染函数,签名与 html-to-image 的 toPng 一致(el, { pixelRatio, backgroundColor })。
* 把画布节点连同页面样式表一起塞进 `<foreignObject>`,再用 canvas 位图化。
* 少了「内联样式表」这一步,隔离文档里渲染出来的就是无样式的纯文本。
* 这份最小实现不做字体与外链图片的内联(隔离文档不加载外部资源),真需要就装 `html-to-image`。
*/
async function toPng(el: HTMLElement, options?: Record<string, any>): Promise<string> {
const ratio = options?.pixelRatio ?? 2
const css = Array.from(document.styleSheets)
.map(sheet => {
try {
return Array.from(sheet.cssRules)
.map(rule => rule.cssText)
.join('')
} catch {
return '' // 跨域样式表读不到规则,跳过
}
})
.join('')
const box = document.createElementNS('http://www.w3.org/1999/xhtml', 'div')
const style = document.createElement('style')
style.textContent = css
box.append(style, el.cloneNode(true))
// 交给 XMLSerializer 转义:CSS 文本里的 & 与 > 手工拼串会破坏 XML 解析
const markup = new XMLSerializer().serializeToString(box)
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" width="${el.offsetWidth}" height="${el.offsetHeight}">` +
`<foreignObject width="100%" height="100%">${markup}</foreignObject></svg>`
const img = new Image()
img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
await img.decode()
const canvas = document.createElement('canvas')
canvas.width = Math.round(el.offsetWidth * ratio)
canvas.height = Math.round(el.offsetHeight * ratio)
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('当前环境拿不到 canvas 2d 上下文')
if (options?.backgroundColor) {
ctx.fillStyle = options.backgroundColor
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
ctx.scale(ratio, ratio)
ctx.drawImage(img, 0, 0)
return canvas.toDataURL('image/png')
}
export function ViewportDemo() {
const vp = useRef<OkrTreeViewportHandle>(null)
const [zoom, setZoom] = useState(1)
const [wheelBehavior, setWheelBehavior] = useState<ViewportWheelBehavior>('ctrl-zoom')
const [status, setStatus] = useState('拖动画布平移;按住 Ctrl / Cmd 滚动缩放。')
const data = useMemo(() => [dept(1, 'xxx科技有有限公司', 3)], [])
async function centerOn(id: number) {
// centerNode 会先展开目标祖先,再把视口中心对准它,返回是否命中
const done = await vp.current?.centerNode(id)
setStatus(done ? `已居中到 id=${id}(祖先已展开)` : `centerNode(${id}) 未命中节点`)
}
async function exportPng() {
setStatus('导出中…')
try {
await vp.current?.exportImage({ type: 'png', scale: 2, background: '#ffffff', toPng })
setStatus('已导出 PNG,浏览器应已触发下载(Promise 以 dataURL 结束)')
} catch (error) {
setStatus(`导出失败:${(error as Error).message}`)
}
}
return (
<div>
<div className="mb-3 flex flex-wrap items-center gap-2">
{WHEEL_MODES.map(mode => (
<button
key={mode.value}
type="button"
onClick={() => setWheelBehavior(mode.value)}
className={`rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors ${
wheelBehavior === mode.value
? 'bg-fd-accent text-fd-accent-foreground'
: 'text-fd-muted-foreground'
}`}
>
{mode.label}
</button>
))}
<button
type="button"
onClick={() => vp.current?.fitToScreen()}
className="rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent"
>
fitToScreen()
</button>
<button
type="button"
onClick={() => void centerOn(121)}
className="rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent"
>
centerNode(121)
</button>
<button
type="button"
onClick={() => void exportPng()}
className="rounded-lg border border-fd-border px-3 py-1.5 text-sm transition-colors hover:bg-fd-accent"
>
导出 PNG(注入 toPng)
</button>
</div>
<OkrTreeViewport
ref={vp}
toolbar
zoom={zoom}
onZoomChange={setZoom}
minZoom={0.3}
maxZoom={3}
wheelBehavior={wheelBehavior}
style={{ height: 420 }}
>
<OkrTree data={data} nodeKey="id" direction="horizontal" showCollapsable />
</OkrTreeViewport>
<p className="mt-2 text-sm text-fd-muted-foreground">
当前缩放 {Math.round(zoom * 100)}% · {status}
</p>
</div>
)
}
过滤
输入关键字试试,清空即恢复全部节点
过滤
输入即 handle.filter(value);清空输入等于恢复全显(filterNodeMethod 里 !value 直接返回 true),多根数据也要一起恢复。
查看源码
'use client'
import { useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
/**
* 节点过滤 + 通过 ref 调用的方法(对应源项目 playground/components/demos/BaseFilter.vue)
*
* **空值恢复**是这里唯一容易写错的语义:`filter('')` 不会走「跳过过滤」的捷径,它会以空值
* 再执行一次 `filterNodeMethod`,所以方法里 `!value` 必须返回 `true`,否则清空关键字后节点
* 再也回不来(源项目修过的 Q1 行为)。父节点自身不匹配但后代命中时它会保持可见。
* 下面的按钮就是源用例那批方法:`getNode` / `setCurrentNode` / `getCurrentKey` / `remove` /
* `append` / `insertBefore` / `insertAfter` / `updateKeyChildren` 都依赖 `nodeKey`,
* 增删类还会同步改你传进来的那份源数据(Q3)。
*
* 与源用例的差别:Vue 用 `watch(filterText)` 触发过滤,React 在 `onChange` 里直接调
* `handle.filter(value)`;默认主题不给选中态内置外观(与原版一致),所以照源用例传
* `currentLableClassName`(保留原版拼写),值写站里现成的 Tailwind 令牌而不是全局样式表。
*/
const BTN =
'rounded-lg border border-fd-border px-2 py-1 text-sm transition-colors hover:bg-fd-accent'
/** 空值直接放行:清空输入 = 显示全部 */
function filterNode(value: string, data: TreeNodeData) {
if (!value) return true
return String(data.label).includes(value)
}
export function FilterDemo() {
const tree = useRef<OkrTreeHandle>(null)
const [filterText, setFilterText] = useState('')
// data 用 state 持有而不是每次渲染现造:换引用 = store 全量重建,展开/选中态都会冲掉(R2)
const [data, setData] = useState(keyedData)
const [log, setLog] = useState('输入关键字试试,清空即恢复全部节点')
function onFilter(value: string) {
setFilterText(value)
tree.current?.filter(value)
}
function getNodeByData() {
const node = tree.current?.getNode({ id: 7, label: '销售一部' })
say(node ? `getNode({ id: 7 }) → ${node.data.label}` : 'getNode({ id: 7 }) → null(已被删除)')
}
function getNodeById() {
const node = tree.current?.getNode(7)
say(node ? `getNode(7) → ${node.data.label}` : 'getNode(7) → null(已被删除)')
}
function setCurrentNode() {
const node = tree.current?.getNode(7)
if (!node) return say('销售一部不存在')
tree.current?.setCurrentNode(node)
say(`setCurrentNode(node) → getCurrentKey() = ${tree.current?.getCurrentKey()}`)
}
function getCurrentNode() {
const node = tree.current?.getCurrentNode()
say(node ? `当前选中的节点是「${node.label}」` : '当前没有选中节点')
}
function clearCurrent() {
tree.current?.setCurrentKey(null)
say(`setCurrentKey(null) → getCurrentKey() = ${tree.current?.getCurrentKey()}`)
}
function remove() {
const node = tree.current?.getNode(2)
if (!node) return say('产品研发部已删除')
tree.current?.remove(node)
say('remove(产品研发部):源数据里那一项也一起没了')
}
function append() {
if (tree.current?.getNode(10)) return say('销售三部已经存在了,不可再增加')
tree.current?.append({ id: 10, label: '销售三部' }, tree.current?.getNode(6))
say('append(销售三部, 销售部) 完成')
}
function insertBefore() {
const ref = tree.current?.getNode(6)
if (!ref) return say('销售部不存在')
if (tree.current?.getNode(11)) return say('销售总部已经存在了,不可再增加')
tree.current?.insertBefore({ id: 11, label: '销售总部' }, ref)
say('insertBefore(销售总部, 销售部) 完成')
}
function insertAfter() {
const ref = tree.current?.getNode(6)
if (!ref) return say('销售部不存在')
if (tree.current?.getNode(11)) return say('销售总部已经存在了,不可再增加')
tree.current?.insertAfter({ id: 11, label: '销售总部' }, ref)
say('insertAfter(销售总部, 销售部) 完成')
}
function updateKeyChildren() {
tree.current?.updateKeyChildren(6, [
{
id: 7,
label: '销售一部',
children: [
{ id: 1117, label: '销售一部--子一' },
{ id: 1118, label: '销售一部--子二' },
],
},
{ id: 8, label: '销售二部' },
{ id: 77, label: '销售三部' },
])
say('updateKeyChildren(6, [...]) 完成')
}
function reset() {
setData(keyedData())
setFilterText('')
say('已重置:换 data 引用 = store 全量重建(过滤与展开态回到初始)')
}
function say(text: string) {
setLog(text)
}
return (
<div className="space-y-3">
<input
type="text"
value={filterText}
onChange={event => onFilter(event.target.value)}
placeholder="输入关键字进行过滤(清空即恢复全部节点)"
className="w-full max-w-sm rounded-lg border border-fd-border px-2 py-1 text-sm"
/>
<div className="flex flex-wrap gap-2">
<button type="button" className={BTN} onClick={getNodeByData}>
通过 data 获取销售一部
</button>
<button type="button" className={BTN} onClick={getNodeById}>
通过 id 获取销售一部
</button>
<button type="button" className={BTN} onClick={setCurrentNode}>
setCurrentNode 选中销售一部
</button>
<button type="button" className={BTN} onClick={getCurrentNode}>
getCurrentNode
</button>
<button type="button" className={BTN} onClick={clearCurrent}>
setCurrentKey(null)
</button>
<button type="button" className={BTN} onClick={remove}>
删除产品研发部
</button>
<button type="button" className={BTN} onClick={append}>
为销售部追加销售三部
</button>
<button type="button" className={BTN} onClick={insertBefore}>
在销售部之前插入销售总部
</button>
<button type="button" className={BTN} onClick={insertAfter}>
在销售部之后插入销售总部
</button>
<button type="button" className={BTN} onClick={updateKeyChildren}>
updateKeyChildren(6, ...)
</button>
<button type="button" className={BTN} onClick={reset}>
重置数据
</button>
</div>
<p className="text-sm text-fd-muted-foreground">{log}</p>
<div className="overflow-x-auto">
<OkrTree
ref={tree}
data={data}
direction="horizontal"
nodeKey="id"
filterNodeMethod={filterNode}
currentLableClassName="bg-fd-primary text-fd-primary-foreground"
/>
</div>
</div>
)
}
两侧都命中的词会一起留下
OKR 模式过滤
一次 filter() 同时作用于左右两棵树,两侧都命中的关键词会一起留下。
查看源码
'use client'
import { useMemo, useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData } from './data'
/**
* OKR 模式下的节点过滤(对应源项目 playground/components/demos/BaseFilterOkr.vue)
*
* 调的还是同一个 `handle.filter(value)`:`onlyBothTree` 下它内部跑两遍遍历,右树走
* `childNodes`、左树走 `leftChildNodes`,所以「销售」这种两侧都命中的词会一起留下,
* 空值恢复也一样(`filterNodeMethod` 里 `!value` 直接 true)。
* 这里刻意不开 `defaultExpandAll`:关键字非空时命中分支会自动展开,收起的树一输入关键字
* 就直接跳到命中处。
*
* 与源用例的差别:左右两棵树可以存在相同 id(本例根都是 1),`getNode` / `setCurrentKey`
* 这类按 key 查找的方法**右树优先、未命中才回退左树**——这是源项目的正式语义。
* 源用例旁边那批方法按钮(getNode / append / updateKeyChildren 等)在普通过滤那一例里
* 已经演示过,这里只留过滤本身。
*/
const KEYWORDS = ['销售', '左', '前端']
/** 空值直接放行:清空输入 = 显示全部 */
function filterNode(value: string, data: TreeNodeData) {
if (!value) return true
return String(data.label).includes(value)
}
export function FilterOkrDemo() {
const tree = useRef<OkrTreeHandle>(null)
const [filterText, setFilterText] = useState('')
const [status, setStatus] = useState('两侧都命中的词会一起留下')
// 两份数据都要稳定:换引用等于 store 全量重建(requirements R2)
const data = useMemo(keyedData, [])
const left = useMemo(leftData, [])
function apply(value: string) {
setFilterText(value)
tree.current?.filter(value)
// getVisibleNodes 含 OKR 左树:一次 filter 之后两侧各命中了多少,这里直接读得出来
const visible = tree.current?.getVisibleNodes() ?? []
const onLeft = visible.filter(node => node.isLeftChild).length
setStatus(
value
? `「${value}」左右合计 ${visible.length} 个可见节点,其中左树 ${onLeft} 个`
: `空值已放行:全部节点恢复可见,当前沿展开路径能看到 ${visible.length} 个`
)
}
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<input
type="text"
value={filterText}
onChange={event => apply(event.target.value)}
placeholder="输入关键字进行过滤(如:销售 / 左 / 前端)"
className="w-full max-w-sm rounded-lg border border-fd-border px-2 py-1 text-sm"
/>
{KEYWORDS.map(word => (
<button
key={word}
type="button"
onClick={() => apply(filterText === word ? '' : word)}
className={`rounded-lg border border-fd-border px-2 py-1 text-sm transition-colors ${
filterText === word
? 'bg-fd-accent text-fd-accent-foreground'
: 'text-fd-muted-foreground'
}`}
>
{word}
</button>
))}
</div>
<p className="text-sm text-fd-muted-foreground">{status}</p>
<div className="overflow-x-auto">
<OkrTree
ref={tree}
data={data}
leftData={left}
onlyBothTree
direction="horizontal"
nodeKey="id"
showCollapsable
filterNodeMethod={filterNode}
currentLableClassName="bg-fd-primary text-fd-primary-foreground"
/>
</div>
</div>
)
}
事件
尚未触发事件,试试点击节点 / 右键 / 展开按钮。
试试:左键 / 右键卡片、点 +/− 圆盘、勾复选框、拖动卡片。复选框与拖拽为了触发对应事件才开着, 用法见各自的用例。
事件回调
14 个事件都是 onXxx 回调 prop。onNodeContextMenu 只有在你传了它的时候才会 preventDefault 掉浏览器原生菜单。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, type TreeKey } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'
/**
* 事件回调总览(对应源项目 playground/components/demos/BaseEvents.vue)
*
* api 表里的 14 个事件在 React 侧全是 `onXxx` 回调 prop,与 Vue 的三点差异:
* 1. 回调参数去掉了源项目的第三参 `nodeComponent`(D2),只有 `(data, node)`;
* 要拿 DOM 用 `handle.getNodeEl(key)`。
* 2. `node-contextmenu` → `onNodeContextMenu`,**只有传了这个 prop 组件才 preventDefault**
* 掉浏览器原生菜单;不传就照常弹原生菜单(要恢复原生菜单,把这条 prop 删掉即可)。
* 3. `event` 参数是 React 合成事件(D11),原生事件在 `event.nativeEvent` 上。
*
* `onExpandedKeysChange` / `onCurrentKeyChange` 只在受控模式下触发(等价 Vue 的
* `v-model:expanded-keys`,React 拆成「值 prop + 回调 prop」一对),所以本例把两个值也传了。
*/
export function EventsDemo() {
// 变量名不叫 data:下面每个回调的首参都是源数据对象,叫 data 会把它遮住
const treeData = useMemo(keyedData, [])
const [lines, setLines] = useState<LogLine[]>([])
const [expandedKeys, setExpandedKeys] = useState<TreeKey[]>([1])
const [currentKey, setCurrentKey] = useState<TreeKey | null>(null)
/** 最新一条插到最前,超出 LOG_LIMIT 直接丢——不截断的话点一分钟就把页面撑爆了 */
function push(event: string, text: string) {
setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
}
return (
<>
<EventLog lines={lines} onClear={() => setLines([])} />
<p className="mb-3 text-sm text-fd-muted-foreground">
试试:左键 / 右键卡片、点 +/− 圆盘、勾复选框、拖动卡片。复选框与拖拽为了触发对应事件才开着,
用法见各自的用例。
</p>
<OkrTree
data={treeData}
nodeKey="id"
direction="horizontal"
showCollapsable
showCheckbox
draggable
expandedKeys={expandedKeys}
currentKey={currentKey}
onNodeClick={(data, node) =>
push('onNodeClick', `「${data.label}」被点击(level ${node.level})`)
}
onNodeExpand={data => push('onNodeExpand', `「${data.label}」展开`)}
onNodeCollapse={data => push('onNodeCollapse', `「${data.label}」收起`)}
// 传了本 prop,组件才会 preventDefault 掉浏览器原生右键菜单
onNodeContextMenu={(event, data) =>
push(
'onNodeContextMenu',
`「${data.label}」右键,原生事件取 event.nativeEvent(此刻坐标 ${Math.round(
event.clientX
)}, ${Math.round(event.clientY)})`
)
}
onCheck={(data, info) =>
push(
'onCheck',
`「${data.label}」勾选变化:已选 ${info.checkedKeys.length} 个,半选 ${info.halfCheckedKeys.length} 个`
)
}
onCheckChange={(data, checked, indeterminate) =>
push(
'onCheckChange',
`「${data.label}」→ ${checked ? '已选' : indeterminate ? '半选' : '未选'}`
)
}
onExpandedKeysChange={keys => {
setExpandedKeys(keys)
push('onExpandedKeysChange', `[${keys.join(', ')}]`)
}}
onCurrentKeyChange={key => {
setCurrentKey(key)
push('onCurrentKeyChange', key === null ? 'null(无选中)' : String(key))
}}
onNodeDragStart={node => push('onNodeDragStart', `开始拖动「${node.label}」`)}
onNodeDragEnter={(dragging, dropNode) =>
push('onNodeDragEnter', `「${dragging.label}」进入「${dropNode.label}」`)
}
onNodeDragLeave={(dragging, dropNode) =>
push('onNodeDragLeave', `「${dragging.label}」离开「${dropNode.label}」`)
}
// dragover 随鼠标移动连续触发,日志会被它刷满——真要分区变化看 onNodeDrop
onNodeDragOver={(dragging, dropNode) =>
push('onNodeDragOver', `悬停在「${dropNode.label}」的放置区内`)
}
onNodeDragEnd={(dragging, dropNode, dropType) =>
push(
'onNodeDragEnd',
dropNode && dropType
? `「${dragging.label}」结束,落点是 ${dropType}`
: `「${dragging.label}」结束,未完成放置`
)
}
onNodeDrop={(dragging, dropNode, dropType) =>
push('onNodeDrop', `「${dragging.label}」→「${dropNode.label}」的 ${dropType}`)
}
/>
</>
)
}
尚未触发事件,试试点击节点 / 右键 / 展开按钮。
OKR 模式事件
左右两棵树走同一批回调;左树节点的 node.isLeftChild 为 true。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData, leftData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'
/**
* OKR 模式下的事件(对应源项目 playground/components/demos/BaseEventsOkr.vue)
*
* 左右两棵子树由**同一个** `<OkrTree>` 渲染,所以触发的也是同一批 `onXxx` 回调——
* 区分左右只有一个字段:`node.isLeftChild`。Vue 用例里那句「点击根节点左右两侧的 +/−
* 分别触发 node-expand / node-collapse」在 React 下形状不变,只是从 `$emit` 换成回调。
*
* 勾选两棵树各自维护状态(左树 id 12–19、右树 id 1–9,根同为 id 1),
* 所以 onCheck / onCheckChange 必须带 side 才看得清是谁变了。
* 拖拽与受控那两类回调与本目录 events.tsx 用例完全同一批,这里不再重复挂。
*/
export function EventsOkrDemo() {
const [data, left] = useMemo(() => [keyedData(), leftData()], [])
const [lines, setLines] = useState<LogLine[]>([])
const side = (node: TreeNode) => (node.isLeftChild ? '左树' : '右树')
function push(event: string, text: string) {
setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
}
return (
<>
<EventLog lines={lines} onClear={() => setLines([])} />
<OkrTree
data={data}
leftData={left}
nodeKey="id"
direction="horizontal"
onlyBothTree
showCollapsable
showCheckbox
defaultExpandAll
onNodeClick={(data, node) => push('onNodeClick', `[${side(node)}] 「${data.label}」被点击`)}
onNodeExpand={(data, node) => push('onNodeExpand', `[${side(node)}] 「${data.label}」展开`)}
onNodeCollapse={(data, node) =>
push('onNodeCollapse', `[${side(node)}] 「${data.label}」收起`)
}
// 只有传了这个 prop,组件才 preventDefault 掉浏览器原生右键菜单
onNodeContextMenu={(_event, data, node) =>
push('onNodeContextMenu', `[${side(node)}] 「${data.label}」右键`)
}
onCheck={(data, info) =>
push(
'onCheck',
`「${data.label}」勾选变化:已选 ${info.checkedKeys.length} 个,半选 ${info.halfCheckedKeys.length} 个`
)
}
onCheckChange={(data, checked, indeterminate) =>
push(
'onCheckChange',
`「${data.label}」→ ${checked ? '已选' : indeterminate ? '半选' : '未选'}`
)
}
/>
</>
)
}
交互
手风琴
accordion 展开一个自动收起同级;只约束交互展开,expandAll() 这类整体操作不受影响。
查看源码
'use client'
import { useMemo, useRef } from 'react'
import { OkrTree, type OkrTreeHandle } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
/**
* 手风琴模式(对应源项目 playground/components/demos/BaseAccordion.vue)
*
* `accordion`:用户展开某个节点时自动收起同级已展开的兄弟(内部走 `collapseSiblings`)。
* 边界与 el-tree 一致——**只约束交互展开**:点 +/− 圆盘、点卡片内容、键盘 ←/→。
* 下面两个按钮是反例:`expandAll()` 与 `expandNode()` 这类程序化方法、以及受控
* `expandedKeys` 都不受互斥限制,全展开后手风琴依然生效于用户的下一次点击。
*/
export function AccordionDemo() {
const data = useMemo(keyedData, [])
const handle = useRef<OkrTreeHandle>(null)
return (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => handle.current?.expandAll()}
className="rounded-lg border border-fd-border bg-fd-accent px-3 py-1 text-sm hover:bg-fd-muted"
>
expandAll() —— 手风琴不管它
</button>
<button
type="button"
onClick={() => handle.current?.collapseAll()}
className="rounded-lg border border-fd-border px-3 py-1 text-sm hover:bg-fd-accent"
>
collapseAll()
</button>
</div>
<OkrTree
ref={handle}
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
accordion
/>
</>
)
}
最近点击:无
点击节点展开
expandOnClickNode:点卡片切展开态,选中与勾选照旧。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
/**
* 点击卡片切换展开(对应源项目 playground/components/demos/BaseNodeClick.vue)
*
* `expandOnClickNode` 默认关闭(保持原版行为:只有 +/− 圆盘能收起)。打开后点卡片内容即切展开,
* 选中态与 `onNodeClick` 照常触发;两个例外按源项目语义保留:
* - **叶子节点**只选中,不切换(没有可切的东西);
* - **OKR 根节点**只切右侧子树,左树仍由左侧圆盘控制。
*
* 与 `showCheckbox` 同时开启时各管各的:勾勾选框不会切换展开,点卡片也不会改勾选。
* 两个开关都是运行时可切换的 prop(组件内有同步 effect),不需要重建。
*/
export function NodeClickDemo() {
const data = useMemo(keyedData, [])
const [expandOnClickNode, setExpandOnClickNode] = useState(true)
const [showCheckbox, setShowCheckbox] = useState(false)
const [current, setCurrent] = useState<string | null>(null)
const btn = 'rounded-lg border border-fd-border px-3 py-1 text-sm'
const active = 'bg-fd-accent font-medium'
return (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setExpandOnClickNode(v => !v)}
className={`${btn} ${expandOnClickNode ? active : ''}`}
>
expandOnClickNode:{expandOnClickNode ? '开' : '关'}
</button>
<button
type="button"
onClick={() => setShowCheckbox(v => !v)}
className={`${btn} ${showCheckbox ? active : ''}`}
>
showCheckbox:{showCheckbox ? '开' : '关'}
</button>
</div>
<p className="mb-3 text-sm text-fd-muted-foreground">最近点击:{current ?? '无'}</p>
<OkrTree
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
expandOnClickNode={expandOnClickNode}
showCheckbox={showCheckbox}
onNodeClick={(data, node) => setCurrent(`「${data.label}」(key ${node.key})`)}
/>
</>
)
}
尚未触发事件,试试点击节点 / 右键 / 展开按钮。
复选框
showCheckbox + checkStrictly(父子联动 vs 各自独立)、半选态,以及 getCheckedKeys / setCheckedKeys 的取与设。
查看源码
'use client'
import { useMemo, useRef, useState } from 'react'
import { OkrTree, type OkrTreeHandle, type TreeCheckInfo, type TreeNodeData } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'
/**
* 复选框模式(对应源项目 playground/components/demos/BaseCheckbox.vue)
*
* `showCheckbox` 开勾选框,父子联动带半选态;`checkStrictly` 关掉联动后勾谁只影响谁(也就没有半选)。
* `onCheck(data, info)` 只在**用户点击**勾选框时触发(`setCheckedKeys()` 不触发),
* `onCheckChange(data, checked, indeterminate)` 则每个状态变化的节点各触发一次,含联动与批量设置。
*
* React 与 Vue 的差别只在读结果:`getCheckedKeys()` / `getHalfCheckedKeys()` 是命令式方法,
* 读的是内部 store 而不是 React state,所以只能在事件回调里取——渲染期取不到,
* 也就没法「派生出勾选态」。本例把结果写进日志面板。
* OKR 模式下左右两树勾选独立维护,方法按 key 对两树同时生效。
*/
export function CheckboxDemo() {
const data = useMemo(keyedData, [])
const handle = useRef<OkrTreeHandle>(null)
const [checkStrictly, setCheckStrictly] = useState(false)
const [lines, setLines] = useState<LogLine[]>([])
function push(event: string, text: string) {
setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
}
function handleCheck(data: TreeNodeData, info: TreeCheckInfo) {
push(
'onCheck',
`「${data.label}」→ 已选 ${info.checkedKeys.length} 个,半选 ${info.halfCheckedKeys.length} 个`
)
}
function handleCheckChange(data: TreeNodeData, checked: boolean, indeterminate: boolean) {
push(
'onCheckChange',
`「${data.label}」→ ${checked ? '已选' : indeterminate ? '半选' : '未选'}`
)
}
function setChecked() {
handle.current?.setCheckedKeys([7, 8])
push(
'setCheckedKeys',
checkStrictly
? '勾选 [7, 8](独立模式:父节点 6 不受影响)'
: '勾选 [7, 8](联动模式:父节点 6 自动变半选)'
)
}
function readKeys() {
const h = handle.current
if (!h) return
push(
'getCheckedKeys',
`checked=[${h.getCheckedKeys().join(', ')}] half=[${h.getHalfCheckedKeys().join(', ')}]`
)
}
const btn = 'rounded-lg border border-fd-border px-3 py-1 text-sm'
return (
<>
<div className="mb-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => setCheckStrictly(v => !v)}
className={`${btn} ${checkStrictly ? 'bg-fd-accent font-medium' : ''}`}
>
checkStrictly(父子不联动):{checkStrictly ? '开' : '关'}
</button>
<button type="button" onClick={setChecked} className={`${btn} hover:bg-fd-accent`}>
setCheckedKeys([7, 8])
</button>
<button type="button" onClick={readKeys} className={`${btn} hover:bg-fd-accent`}>
getCheckedKeys / getHalfCheckedKeys
</button>
</div>
<EventLog lines={lines} onClear={() => setLines([])} />
<OkrTree
ref={handle}
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
showCheckbox
checkStrictly={checkStrictly}
defaultCheckedKeys={[3, 4]}
onCheck={handleCheck}
onCheckChange={handleCheckChange}
/>
</>
)
}
尚未触发事件,试试点击节点 / 右键 / 展开按钮。
拖拽排序
draggable + allowDrag / allowDrop 谓词;卡片上/中/下三段分别是 prev / inner / next,禁止放进自身子树。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, type DropType, type TreeNode } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
import { EventLog, LOG_LIMIT, type LogLine } from './event-log'
/**
* 拖拽调整层级(对应源项目 playground/components/demos/BaseDraggable.vue)
*
* `draggable` 开 HTML5 拖拽,落点按目标卡片的 25% / 50% / 25% 分三区:
* prev(排在目标前)/ inner(成为目标的子节点,目标自动展开)/ next(排在目标后)。
* **分区轴随方向换**:`direction="horizontal"` 时同层是上下排列,按 Y 轴分;
* 默认 vertical 时同层左右排列,按 X 轴分。指示线颜色走 `--okr-drop-color`。
*
* 两条硬性规则不是 `allowDrop` 能改的:不可放进自身或自己的子树;OKR 下跨左右树默认禁止
* (`allowDrop` 明确返回 true 才放开)。移动会同步回写源数据的 children。
*
* 回调参数与 Vue 同形,只是没有第三参 `nodeComponent`(D2):
* `onNodeDrop(draggingNode, dropNode, dropType)` 三个都是内部 TreeNode。
* 程序化入口是 `handle.moveNode(7, 2, 'inner')`,规则一致(本例未挂按钮)。
*/
export function DraggableDemo() {
const data = useMemo(keyedData, [])
const [lines, setLines] = useState<LogLine[]>([])
function push(event: string, text: string) {
setLines(prev => [{ event, text }, ...prev].slice(0, LOG_LIMIT))
}
/** 示例规则:财务部(id 9)不许被拖走 */
function allowDrag(node: TreeNode) {
return node.key !== 9
}
/** 示例规则:财务部也不能当落点;叶子不接受 inner(放成子节点后它就成了父级) */
function allowDrop(_dragging: TreeNode, target: TreeNode, type: DropType) {
if (target.key === 9) return false
return type !== 'inner' || !target.isLeaf
}
return (
<>
<EventLog lines={lines} onClear={() => setLines([])} />
<OkrTree
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
draggable
allowDrag={allowDrag}
allowDrop={allowDrop}
onNodeDragStart={node => push('onNodeDragStart', `开始拖动「${node.label}」`)}
onNodeDrop={(dragging, target, type) =>
push('onNodeDrop', `「${dragging.label}」→「${target.label}」的 ${type}`)
}
onNodeDragEnd={(dragging, target, type) =>
push(
'onNodeDragEnd',
target && type ? `「${dragging.label}」放置完成` : `「${dragging.label}」未完成放置`
)
}
/>
</>
)
}
SVG 连接线
connector="svg" 换成覆盖层路径,布局零改动;三种形状在收起子树时会跟着重绘。
查看源码
'use client'
import { useMemo, useState } from 'react'
import { OkrTree, type ConnectorMode, type ConnectorShape } from 'react-okr-tree'
import 'react-okr-tree/style.css'
import { keyedData } from './data'
/**
* SVG 连接线(对应源项目 playground/components/demos/BaseConnector.vue)
*
* `connector="css"`(默认,伪元素像素几何)与 `connector="svg"`(覆盖层路径)可运行时切换,
* 两者**布局完全一致**,svg 只替换线条渲染,随展开收起、`animate` 过渡、尺寸变化自动重绘。
* `connectorShape` 三种形状只在 svg 模式下有意义,所以非 svg 时把形状按钮 `disabled` 掉——
* 传了也不会生效,按钮状态比一句提示诚实。
* 线色线宽继续走 `--okr-line-color` / `--okr-line-width`,主题变量与 `--okr-drop-color` 通用。
*/
export function ConnectorDemo() {
const data = useMemo(keyedData, [])
const [connector, setConnector] = useState<ConnectorMode>('svg')
const [connectorShape, setConnectorShape] = useState<ConnectorShape>('curve')
const btn =
'rounded-lg border border-fd-border px-3 py-1 text-sm disabled:cursor-not-allowed disabled:opacity-50'
return (
<>
<div className="mb-3 flex flex-wrap items-center gap-2">
{(['css', 'svg'] as const).map(mode => (
<button
key={mode}
type="button"
onClick={() => setConnector(mode)}
className={`${btn} ${connector === mode ? 'bg-fd-accent font-medium' : ''}`}
>
connector="{mode}"
</button>
))}
<span className="text-sm text-fd-muted-foreground">形状(仅 svg 生效)</span>
{(['curve', 'orthogonal', 'straight'] as const).map(shape => (
<button
key={shape}
type="button"
disabled={connector !== 'svg'}
onClick={() => setConnectorShape(shape)}
className={`${btn} ${connectorShape === shape ? 'bg-fd-accent font-medium' : ''}`}
>
{shape}
</button>
))}
</div>
<OkrTree
data={data}
nodeKey="id"
direction="horizontal"
showCollapsable
defaultExpandedKeys={[1]}
animate
connector={connector}
connectorShape={connectorShape}
/>
</>
)
}